From f5c9e61589d451c8b0900695104037049cbdfa7b Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 30 Aug 2026 20:58:19 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=80=D1=82=20=D1=80=D0=B5=D0=B4?= =?UTF-8?q?=D0=B0=D0=BA=D1=82=D0=BE=D1=80=D0=B0=20SimVulcan=20=D0=BD=D0=B0?= =?UTF-8?q?=20Rust?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Та же программа на ash + winit + egui: орбитальная камера, три плоскости сетки, загрузка .obj, три режима отображения, единый проход динамического рендеринга с глубинным вложением и submit через synchronization2. Шейдеры общие с C++-версией — build.rs компилирует их из shaders/ и вшивает SPIR-V в бинарь. Пять крейтов повторяют карту целей CMake, но инвариант «весь Vulkan в одном месте» теперь проверяет компилятор: у simv-mesh и simv-editor нет ash в зависимостях. Расхождения, вынужденные языком, а не вкусом: * App переехал в исполняемый крейт — в C++ граф целей цикличен (core владеет vk, vk принимает core::Window&), и держится это лишь на том, что simv_vk видит заголовки core, не линкуя цель; Cargo такой цикл отвергает; * состояние сцены возвращается из замыкания интерфейса, а не ставится сеттерами: замыкание вызывается из метода рендерера, трогать рендерер оттуда нельзя; * выгрузка меша на видеокарту уехала из середины записи кадра, где C++-версия зовёт vkDeviceWaitIdle уже после захвата образа цепочки показа. Vulkan-контекст и цепочка показа написаны руками: замены vk-bootstrap в экосистеме Rust нет. Обёртки Buffer и Image, лежащие в C++-версии без применения, здесь задействованы всеми потребителями. 13 тестов, из них три перенесены из Catch2 дословно. cargo clippy -D warnings чист, слой валидации молчит. Co-Authored-By: Claude Opus 5 --- .gitignore | 7 + rust/Cargo.lock | 2475 +++++++++++++++++ rust/Cargo.toml | 42 + rust/README.md | 95 + rust/crates/simv-app/Cargo.toml | 25 + rust/crates/simv-app/src/app.rs | 205 ++ rust/crates/simv-app/src/main.rs | 21 + rust/crates/simv-core/Cargo.toml | 13 + rust/crates/simv-core/src/lib.rs | 13 + rust/crates/simv-core/src/logger.rs | 230 ++ rust/crates/simv-core/src/window.rs | 67 + rust/crates/simv-editor/Cargo.toml | 15 + rust/crates/simv-editor/src/camera.rs | 152 + rust/crates/simv-editor/src/camera_input.rs | 30 + rust/crates/simv-editor/src/lib.rs | 13 + .../crates/simv-editor/src/mesh_load_panel.rs | 156 ++ rust/crates/simv-editor/src/ui.rs | 80 + rust/crates/simv-mesh/Cargo.toml | 16 + rust/crates/simv-mesh/src/decimator.rs | 169 ++ rust/crates/simv-mesh/src/lib.rs | 13 + rust/crates/simv-mesh/src/mesh.rs | 77 + rust/crates/simv-mesh/src/obj_loader.rs | 153 + rust/crates/simv-vk/Cargo.toml | 26 + rust/crates/simv-vk/build.rs | 95 + rust/crates/simv-vk/src/buffer.rs | 141 + rust/crates/simv-vk/src/context.rs | 570 ++++ rust/crates/simv-vk/src/error.rs | 43 + rust/crates/simv-vk/src/gpu_mesh.rs | 158 ++ rust/crates/simv-vk/src/grid_renderer.rs | 257 ++ rust/crates/simv-vk/src/image.rs | 182 ++ rust/crates/simv-vk/src/lib.rs | 26 + rust/crates/simv-vk/src/mesh_renderer.rs | 238 ++ rust/crates/simv-vk/src/render_mode.rs | 13 + rust/crates/simv-vk/src/renderer.rs | 626 +++++ rust/crates/simv-vk/src/shader.rs | 55 + rust/crates/simv-vk/src/swapchain.rs | 182 ++ 36 files changed, 6679 insertions(+) create mode 100644 rust/Cargo.lock create mode 100644 rust/Cargo.toml create mode 100644 rust/README.md create mode 100644 rust/crates/simv-app/Cargo.toml create mode 100644 rust/crates/simv-app/src/app.rs create mode 100644 rust/crates/simv-app/src/main.rs create mode 100644 rust/crates/simv-core/Cargo.toml create mode 100644 rust/crates/simv-core/src/lib.rs create mode 100644 rust/crates/simv-core/src/logger.rs create mode 100644 rust/crates/simv-core/src/window.rs create mode 100644 rust/crates/simv-editor/Cargo.toml create mode 100644 rust/crates/simv-editor/src/camera.rs create mode 100644 rust/crates/simv-editor/src/camera_input.rs create mode 100644 rust/crates/simv-editor/src/lib.rs create mode 100644 rust/crates/simv-editor/src/mesh_load_panel.rs create mode 100644 rust/crates/simv-editor/src/ui.rs create mode 100644 rust/crates/simv-mesh/Cargo.toml create mode 100644 rust/crates/simv-mesh/src/decimator.rs create mode 100644 rust/crates/simv-mesh/src/lib.rs create mode 100644 rust/crates/simv-mesh/src/mesh.rs create mode 100644 rust/crates/simv-mesh/src/obj_loader.rs create mode 100644 rust/crates/simv-vk/Cargo.toml create mode 100644 rust/crates/simv-vk/build.rs create mode 100644 rust/crates/simv-vk/src/buffer.rs create mode 100644 rust/crates/simv-vk/src/context.rs create mode 100644 rust/crates/simv-vk/src/error.rs create mode 100644 rust/crates/simv-vk/src/gpu_mesh.rs create mode 100644 rust/crates/simv-vk/src/grid_renderer.rs create mode 100644 rust/crates/simv-vk/src/image.rs create mode 100644 rust/crates/simv-vk/src/lib.rs create mode 100644 rust/crates/simv-vk/src/mesh_renderer.rs create mode 100644 rust/crates/simv-vk/src/render_mode.rs create mode 100644 rust/crates/simv-vk/src/renderer.rs create mode 100644 rust/crates/simv-vk/src/shader.rs create mode 100644 rust/crates/simv-vk/src/swapchain.rs diff --git a/.gitignore b/.gitignore index 95d5975..2374a8b 100644 --- a/.gitignore +++ b/.gitignore @@ -40,6 +40,13 @@ docs/theory/solver_2x_sdf/out/ # ноутбук kbc_lbm.ipynb их встраивает, и без них он нечитаем на свежем клоне # (перегенерация требует GPU-сервера). См. заметку о них в README ветки research. +# --------------------------------------------------------------------------- +# Rust (rust/ — порт редактора) +# --------------------------------------------------------------------------- +# Каталог сборки cargo: объектники, зависимости, инкрементальный кэш. +target/ +# Cargo.lock для приложения коммитят (воспроизводимость сборки) — он НЕ игнорируется. + # --------------------------------------------------------------------------- # Редакторы и ОС # --------------------------------------------------------------------------- diff --git a/rust/Cargo.lock b/rust/Cargo.lock new file mode 100644 index 0000000..5ec0344 --- /dev/null +++ b/rust/Cargo.lock @@ -0,0 +1,2475 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "accesskit" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" +dependencies = [ + "uuid", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "android-activity" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" +dependencies = [ + "android-properties", + "bitflags 2.13.1", + "cc", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "num_enum", + "thiserror 2.0.20", +] + +[[package]] +name = "android-properties" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7eb209b1518d6bb87b283c20095f5228ecda460da70b44f0802523dea6da04" + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" + +[[package]] +name = "as-raw-xcb-connection" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175571dd1d178ced59193a6fc02dde1b972eb0bc56c892cde9beeceac5bf0f6b" + +[[package]] +name = "ash" +version = "0.38.0+1.3.281" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" +dependencies = [ + "libloading", +] + +[[package]] +name = "ash-window" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bca67b61cb81e5553babde81b8211f713cb6db79766f80168f3e5f40ea6c82" +dependencies = [ + "ash", + "raw-window-handle", + "raw-window-metal", +] + +[[package]] +name = "atomic-waker" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "calloop" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" +dependencies = [ + "bitflags 2.13.1", + "log", + "polling", + "rustix 0.38.44", + "slab", + "thiserror 1.0.69", +] + +[[package]] +name = "calloop-wayland-source" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95a66a987056935f7efce4ab5668920b5d0dac4a7c99991a67395f13702ddd20" +dependencies = [ + "calloop", + "rustix 0.38.44", + "wayland-backend", + "wayland-client", +] + +[[package]] +name = "cc" +version = "1.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ad534f4357a5264cce5019c989cf66a4f0dc4e0d1b1d15f8aacec0ff7360273" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "cocoa" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6140449f97a6e97f9511815c5632d84c8aacf8ac271ad77c559218161a1373c" +dependencies = [ + "bitflags 1.3.2", + "block", + "cocoa-foundation", + "core-foundation", + "core-graphics", + "foreign-types", + "libc", + "objc", +] + +[[package]] +name = "cocoa-foundation" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c6234cbb2e4c785b456c0644748b1ac416dd045799740356f8363dfe00c93f7" +dependencies = [ + "bitflags 1.3.2", + "block", + "core-foundation", + "core-graphics-types", + "libc", + "objc", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c07782be35f9e1140080c6b96f0d44b739e2278479f64e02fdab4e32dfd8b081" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "dispatch" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "dlib" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" +dependencies = [ + "libloading", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" + +[[package]] +name = "ecolor" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc883f505d446814c0226bef4b64564b9adb9966fc00c88c57ce6d827dd152d6" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c977ac91dfaa651633fd9722e4ce9ccb32cda4c748b89a5cb57e504036e37c13" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", + "web-sys", +] + +[[package]] +name = "egui-ash-renderer" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5366921cf6111f490b2a8a2ac9fe44800afe9ae8dc95b693ebb6f9dd791e00ef" +dependencies = [ + "ash", + "egui", + "gpu-allocator", + "log", + "thiserror 2.0.20", +] + +[[package]] +name = "egui-winit" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9327fc8edef2c57db9bcbcacc82c8a4b1e8cc64cd41a67db4419dcf643d88c83" +dependencies = [ + "bytemuck", + "egui", + "log", + "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-ui-kit 0.3.2", + "profiling", + "raw-window-handle", + "web-time", + "winit", +] + +[[package]] +name = "either" +version = "1.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" + +[[package]] +name = "emath" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce03cf1604f74515830012c29991beb0b58f69e4e43f5afe22fa7afed65ccd5" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "epaint" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11863a6b2b7823010c1090ddf4706947bdda46766742def0673195cbf7ff4a73" +dependencies = [ + "ahash", + "bytemuck", + "ecolor", + "emath", + "epaint_default_fonts", + "font-types", + "harfrust", + "log", + "nohash-hasher", + "parking_lot", + "profiling", + "self_cell", + "skrifa", + "smallvec", + "unicode-general-category", + "unicode-segmentation", + "vello_cpu", +] + +[[package]] +name = "epaint_default_fonts" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18dee69613aac468922cf28a32025eb7d7ed6985b61f73245848e58f37876c98" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "euclid" +version = "0.22.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06" +dependencies = [ + "num-traits", +] + +[[package]] +name = "fearless_simd" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97b65636e5b9ef369943878ac74335ba1c55c1cb6adbf1e2c293c624248d693" + +[[package]] +name = "find-msvc-tools" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "font-types" +version = "0.12.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e64eb721ca85a34323425f4041adc5d82704d3782d5f8f03793bc012419dce23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix 1.1.4", + "windows-link", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "glam" +version = "0.33.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21fef0953c54fd3de2f44b743fbf77e044c81a25faee03636dfccc0d35135e23" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "glifo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed4a1bb24121291d27230c1b1b44e07d6a9b28cefdb32fe1581dfb84e14f940a" +dependencies = [ + "bytemuck", + "foldhash", + "hashbrown", + "log", + "peniko", + "skrifa", + "smallvec", + "vello_common", +] + +[[package]] +name = "gpu-allocator" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51255ea7cfaadb6c5f1528d43e92a82acb2b96c43365989a28b2d44ee38f8795" +dependencies = [ + "ash", + "log", + "presser", + "thiserror 2.0.20", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "harfrust" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c03d949a14aa089bbb282f7dd76a498a7f684428e4257202efc119ec010376f9" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "indexmap" +version = "2.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07aa2048142242915a31d35844fb311e0e53fcca590c3a0a40dcf1b841fa09eb" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys 0.4.1", + "log", + "simd_cesu8", + "thiserror 2.0.20", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "kurbo" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b60dfc32f652b926df6192e55525b16d186c69d47876c3ead4da5cc9f8450e2" +dependencies = [ + "arrayvec", + "euclid", + "polycool", + "smallvec", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libredox" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7955dfc218a8afb29dfeffd540e3a6e96baeb94fe7138228dd7cc6937fbbf96" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.3", +] + +[[package]] +name = "linebender_resource_handle" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a5ff6bcca6c4867b1c4fd4ef63e4db7436ef363e0ad7531d1558856bae64f4" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.13.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "nohash-hasher" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "objc-sys" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" + +[[package]] +name = "objc2" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804" +dependencies = [ + "objc-sys", + "objc2-encode", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", +] + +[[package]] +name = "objc2-app-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" +dependencies = [ + "bitflags 2.13.1", + "block2", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core", +] + +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-data" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.13.1", + "dispatch2", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-core-image" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-contacts", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-foundation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" +dependencies = [ + "bitflags 2.13.1", + "block2", + "dispatch", + "libc", + "objc2 0.5.2", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal", +] + +[[package]] +name = "objc2-symbols" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" +dependencies = [ + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation 0.2.2", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.13.1", + "block2", + "objc2 0.5.2", + "objc2-core-location", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall 0.5.18", + "smallvec", + "windows-link", +] + +[[package]] +name = "peniko" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "839c8299360d2e998bdb106dc0a6cd71dcc5f4df51df1b620361bf50e283cca6" +dependencies = [ + "bytemuck", + "color", + "kurbo", + "linebender_resource_handle", + "smallvec", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "raw-window-metal" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8caa82e31bb98fee12fa8f051c94a6aa36b07cddb03f0d4fc558988360ff1" +dependencies = [ + "cocoa", + "core-graphics", + "objc", + "raw-window-handle", +] + +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types", + "once_cell", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags 1.3.2", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "redox_syscall" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d678d17679829e73d371e96880897e98fee2ded7acc0a50bdf8af2affa4b2fe5" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "sctk-adwaita" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6277f0217056f77f1d8f49f2950ac6c278c0d607c45f5ee99328d792ede24ec" +dependencies = [ + "ab_glyph", + "log", + "memmap2", + "smithay-client-toolkit", + "tiny-skia", +] + +[[package]] +name = "self_cell" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "simv-app" +version = "0.1.0" +dependencies = [ + "anyhow", + "egui", + "glam", + "log", + "simv-core", + "simv-editor", + "simv-mesh", + "simv-vk", + "winit", +] + +[[package]] +name = "simv-core" +version = "0.1.0" +dependencies = [ + "chrono", + "log", + "winit", +] + +[[package]] +name = "simv-editor" +version = "0.1.0" +dependencies = [ + "egui", + "glam", + "log", + "simv-core", + "simv-mesh", +] + +[[package]] +name = "simv-mesh" +version = "0.1.0" +dependencies = [ + "glam", + "log", + "simv-core", + "thiserror 2.0.20", + "tobj", +] + +[[package]] +name = "simv-vk" +version = "0.1.0" +dependencies = [ + "ash", + "ash-window", + "bytemuck", + "egui", + "egui-ash-renderer", + "egui-winit", + "glam", + "gpu-allocator", + "log", + "raw-window-handle", + "simv-core", + "simv-mesh", + "thiserror 2.0.20", + "winit", +] + +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "smithay-client-toolkit" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" +dependencies = [ + "bitflags 2.13.1", + "calloop", + "calloop-wayland-source", + "cursor-icon", + "libc", + "log", + "memmap2", + "rustix 0.38.44", + "thiserror 1.0.69", + "wayland-backend", + "wayland-client", + "wayland-csd-frame", + "wayland-cursor", + "wayland-protocols", + "wayland-protocols-wlr", + "wayland-scanner", + "xkeysym", +] + +[[package]] +name = "smol_str" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" +dependencies = [ + "serde", +] + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6275cddf4610d1775e6d1fe9469b2e77d0f39fd98fb7450901b821e0c53649f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl 2.0.20", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.4", +] + +[[package]] +name = "tiny-skia" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83d13394d44dae3207b52a326c0c85a8bf87f1541f23b0d143811088497b09ab" +dependencies = [ + "arrayref", + "arrayvec", + "bytemuck", + "cfg-if", + "log", + "tiny-skia-path", +] + +[[package]] +name = "tiny-skia-path" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e7fc0c2e86a30b117d0462aa261b72b7a99b7ebd7deb3a14ceda95c5bdc93" +dependencies = [ + "arrayref", + "bytemuck", + "strict-num", +] + +[[package]] +name = "tobj" +version = "4.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eb8e04167c1c0c76b5de63226fd733485ad63ef71e40de31e16272f47b099e2" +dependencies = [ + "ahash", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" +dependencies = [ + "indexmap", + "toml_datetime", + "toml_parser", + "winnow", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "uuid" +version = "1.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812" + +[[package]] +name = "vello_common" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e9aed918117e8152c9eddfd8362d73c465c23f26c44786aa331707b8a64fa2" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "log", + "peniko", + "smallvec", + "thiserror 2.0.20", +] + +[[package]] +name = "vello_cpu" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac7349e1f55f6b801c7c277958df4ea53e7f20f21e8014910ad888b2ecda93ea" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown", + "vello_common", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.1", + "rustix 1.1.4", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-csd-frame" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" +dependencies = [ + "bitflags 2.13.1", + "cursor-icon", + "wayland-backend", +] + +[[package]] +name = "wayland-cursor" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a52d18780be9b1314328a3de5f930b73d2200112e3849ca6cb11822793fb34d" +dependencies = [ + "rustix 1.1.4", + "wayland-client", + "xcursor", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-plasma" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.1", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "dlib", + "log", + "once_cell", + "pkg-config", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winit" +version = "0.30.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6755fa58a9f8350bd1e472d4c3fcc25f824ec358933bba33306d0b63df5978d" +dependencies = [ + "ahash", + "android-activity", + "atomic-waker", + "bitflags 2.13.1", + "block2", + "bytemuck", + "calloop", + "cfg_aliases", + "concurrent-queue", + "core-foundation", + "core-graphics", + "cursor-icon", + "dpi", + "js-sys", + "libc", + "memmap2", + "ndk", + "objc2 0.5.2", + "objc2-app-kit", + "objc2-foundation 0.2.2", + "objc2-ui-kit 0.2.2", + "orbclient", + "percent-encoding", + "pin-project", + "raw-window-handle", + "redox_syscall 0.4.1", + "rustix 0.38.44", + "sctk-adwaita", + "smithay-client-toolkit", + "smol_str", + "tracing", + "unicode-segmentation", + "wasm-bindgen", + "wasm-bindgen-futures", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-plasma", + "web-sys", + "web-time", + "windows-sys 0.52.0", + "x11-dl", + "x11rb", + "xkbcommon-dl", +] + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" +dependencies = [ + "memchr", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "as-raw-xcb-connection", + "gethostname", + "libc", + "libloading", + "once_cell", + "rustix 1.1.4", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + +[[package]] +name = "xcursor" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" + +[[package]] +name = "xkbcommon-dl" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" +dependencies = [ + "bitflags 2.13.1", + "dlib", + "log", + "once_cell", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] diff --git a/rust/Cargo.toml b/rust/Cargo.toml new file mode 100644 index 0000000..fa35c3d --- /dev/null +++ b/rust/Cargo.toml @@ -0,0 +1,42 @@ +# Порт редактора SimVulcan на Rust. Пять крейтов повторяют карту целей CMake из +# корня репозитория (simv_core / simv_vk / simv_mesh / simv_editor + исполняемый файл), +# и это не косметика: главный инвариант проекта — «весь Vulkan живёт в одном месте» — +# здесь проверяется компилятором. У simv-mesh и simv-editor просто нет ash в +# зависимостях, так что нарушить границу нельзя даже случайно. +[workspace] +resolver = "2" +members = ["crates/*"] + +[workspace.package] +version = "0.1.0" +edition = "2021" +rust-version = "1.82" +license = "MIT" + +# Версии закреплены здесь, крейты ссылаются через `workspace = true`. +[workspace.dependencies] +simv-core = { path = "crates/simv-core" } +simv-mesh = { path = "crates/simv-mesh" } +simv-vk = { path = "crates/simv-vk" } +simv-editor = { path = "crates/simv-editor" } + +ash = "0.38" +ash-window = "0.13" +raw-window-handle = "0.6" +winit = "0.30" +gpu-allocator = { version = "0.28", default-features = false, features = ["vulkan"] } +egui = "0.36" +egui-winit = { version = "0.36", default-features = false, features = ["wayland", "x11"] } +egui-ash-renderer = { version = "0.13", features = ["gpu-allocator"] } +glam = { version = "0.33", features = ["bytemuck"] } +tobj = "4" +log = "0.4" +chrono = { version = "0.4", default-features = false, features = ["clock", "std"] } +thiserror = "2" +anyhow = "1" +bytemuck = { version = "1", features = ["derive"] } + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 diff --git a/rust/README.md b/rust/README.md new file mode 100644 index 0000000..bc7a081 --- /dev/null +++ b/rust/README.md @@ -0,0 +1,95 @@ +# SimVulcan на Rust + +Порт редактора `SimVulcan` с C++20 на Rust. Делает то же самое: орбитальная камера над +тремя опорными плоскостями сетки, цветные оси XYZ, загрузка `.obj` и три режима +отображения модели. Тот же Vulkan 1.3 с динамическим рендерингом и synchronization2, +те же шейдеры. + +Существует ради сравнения: обе версии лежат в репозитории рядом и собираются +независимо. Разбор различий с цифрами — в [`../docs/rust_vs_cpp.md`](../docs/rust_vs_cpp.md). + +## Требования + +* Rust 1.82+ (проверялось на 1.97.1) +* Vulkan SDK 1.3.290+ — нужен только `glslangValidator` для сборки шейдеров + (ищется в `%VULKAN_SDK%\Bin`, затем в `PATH`) +* Драйвер Vulkan 1.3 с `fillModeNonSolid` + +## Сборка и запуск + +```sh +cd rust +cargo build --release +./target/release/SimVulcan +``` + +Запускать можно из любого каталога. Шейдеры вшиты в исполняемый файл на этапе сборки, +а `assets/meshes` ищется подъёмом по предкам от самого бинаря — в отличие от +C++-версии, которой нужен запуск из её собственного каталога. + +Рядом с исполняемым файлом создаётся `pipeline_cache.bin` — сериализованный +`VkPipelineCache`, он перечитывается при следующем запуске. Файл одноразовый: если +конвейеры повели себя странно, его можно удалить. + +Слой валидации Khronos запрашивается во всех конфигурациях, включая `--release`, — как +и в C++-версии. Если слоя нет, приложение об этом предупредит и пойдёт дальше. + +**Для работы над кодом собирайте `cargo build` без `--release`.** В профиле выпуска стоит +`lto = "thin"`, и правка одной строки заставляет заново оптимизировать и слинковать весь +бинарь: 52 секунды против 5 в отладочной сборке. Замеры — в +[`../docs/rust_vs_cpp.md`](../docs/rust_vs_cpp.md). + +## Тесты + +```sh +cargo test +``` + +Чистая математика без видеокарты: габариты меша, сварка вершин, чтение `Cube.obj`, +пространство отсечения камеры. Тесты живут прямо в модулях (`#[cfg(test)] mod tests`), +отдельной цели под них нет. + +## Раскладка + +Пять крейтов повторяют карту целей CMake из корня репозитория: + +``` +crates/simv-core/ журнал с категориями, окно → log, chrono, winit +crates/simv-mesh/ чтение .obj, сварка вершин, габариты → glam, tobj +crates/simv-vk/ весь Vulkan + build.rs (GLSL → SPIR-V) → ash, gpu-allocator, + egui-ash-renderer +crates/simv-editor/ камера, ввод, панели → egui +crates/simv-app/ App и точка входа → всё вышеперечисленное +``` + +Главный инвариант проекта — «весь Vulkan живёт в одном месте» — здесь проверяет +компилятор: у `simv-mesh` и `simv-editor` в зависимостях нет ни `ash`, ни `simv-vk`, +так что нарушить границу нельзя даже по невнимательности. + +Шейдеры общие с C++-версией: `build.rs` крейта `simv-vk` компилирует +`../../shaders/editor/*.{vert,frag}` тем же `glslangValidator -V --target-env vulkan1.3` +и кладёт SPIR-V в `OUT_DIR`, откуда он попадает в бинарь через `include_bytes!`. + +## Чем отличается от C++-версии + +Снаружи — почти ничем, кроме внешнего вида панелей: вместо Dear ImGui взят **egui**. +Причина: крейт `imgui` — это биндинги, и сборка всё равно тянула бы за собой исходники +Dear ImGui на C++, что обесценило бы сравнение. + +Внутри отличия сведены в отдельный документ, здесь только самые заметные: + +* **`App` живёт в исполняемом крейте**, а не в `simv-core`. В C++ граф целей цикличен + (`core::App` владеет `vk::Renderer`, `vk::Renderer` принимает `core::Window&`), и + держится это лишь на том, что `simv_vk` не линкует `simv_core`, а видит его заголовки. + Cargo цикл между крейтами отвергает. +* **Состояние сцены возвращается из замыкания интерфейса**, а не ставится сеттерами. + Замыкание вызывается из метода рендерера, поэтому трогать рендерер оттуда нельзя. +* **Загруженный меш выгружается на видеокарту после кадра**, а не из середины записи + команд, как в C++, где колбэк панели дёргает `vkDeviceWaitIdle` уже после захвата + образа цепочки показа. +* **`Buffer` и `Image` задействованы.** В C++ обе обёртки собираются в цель `simv_vk`, но + ими не пользуется никто. +* **Нет pImpl.** Приватные поля модуля дают ту же изоляцию, ради которой в C++ заведён + `struct Renderer::Impl`. +* **Цикл событий принадлежит winit.** `ShouldClose`/`PollEvents` заменены методами + `ApplicationHandler`. diff --git a/rust/crates/simv-app/Cargo.toml b/rust/crates/simv-app/Cargo.toml new file mode 100644 index 0000000..a6d0ce5 --- /dev/null +++ b/rust/crates/simv-app/Cargo.toml @@ -0,0 +1,25 @@ +# Исполняемый файл. Здесь же живёт App — в C++-версии он лежит в simv_core, но там +# граф целей цикличен (core владеет vk, vk принимает core::Window), а Cargo цикл +# отвергает. Подробности — в docs/rust_vs_cpp.md. +[package] +name = "simv-app" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Редактор SimVulcan: сборка окна, рендерера и панелей" + +[[bin]] +name = "SimVulcan" +path = "src/main.rs" + +[dependencies] +simv-core.workspace = true +simv-mesh.workspace = true +simv-vk.workspace = true +simv-editor.workspace = true +egui.workspace = true +winit.workspace = true +glam.workspace = true +log.workspace = true +anyhow.workspace = true diff --git a/rust/crates/simv-app/src/app.rs b/rust/crates/simv-app/src/app.rs new file mode 100644 index 0000000..dbd6647 --- /dev/null +++ b/rust/crates/simv-app/src/app.rs @@ -0,0 +1,205 @@ +//! Владелец времени жизни: окно, рендерер, камера, панели — перенос `simv::core::App` +//! вместе с обвязкой из `main.cpp`. +//! +//! Живёт в исполняемом крейте, а не в `simv-core`, где ему соответствовал бы прообраз. +//! Причина в графе зависимостей: в C++ `simv_core` содержит `App`, который владеет +//! `vk::Renderer`, а `simv_vk` принимает `core::Window&` — то есть цели ссылаются друг +//! на друга по кругу. Сходится это лишь потому, что `simv_vk` не линкует `simv_core`, а +//! видит его заголовки через `target_include_directories`, и всё склеивается на +//! компоновке исполняемого файла. Cargo цикл между крейтами отвергает, так что `App` +//! пришлось поднять на уровень выше обоих. +//! +//! Второе отличие — сам цикл. У GLFW его пишет приложение +//! (`while (!ShouldClose()) { PollEvents(); DrawFrame(); }`), у winit цикл владеет +//! собой и зовёт приложение через `ApplicationHandler`. Кадр по-прежнему рисуется +//! непрерывно: `about_to_wait` каждый раз просит перерисовку. + +use std::path::PathBuf; + +use winit::application::ApplicationHandler; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, ControlFlow}; +use winit::window::WindowId; + +use simv_core::{Window, WindowDesc}; +use simv_editor::{apply_mouse_input, Camera, DisplayMode, EditorUi, MeshLoadPanel}; +use simv_mesh::weld_vertices; +use simv_vk::{FrameState, RenderMode, Renderer}; + +/// Допуск сварки вершин после загрузки — как в C++-версии. +const WELD_TOLERANCE: f32 = 1e-4; + +#[derive(Default)] +pub struct App { + window: Option, + renderer: Option, + camera: Camera, + editor_ui: EditorUi, + mesh_panel: MeshLoadPanel, + /// Ошибка, из-за которой цикл был прерван. Возвращается из `main` кодом выхода. + failure: Option, +} + +impl App { + /// Забрать причину аварийного завершения, если она была. + pub fn into_result(self) -> anyhow::Result<()> { + match self.failure { + Some(e) => Err(e), + None => Ok(()), + } + } + + fn fail(&mut self, event_loop: &ActiveEventLoop, error: anyhow::Error) { + log::error!(target: "core", "{error:#}"); + self.failure = Some(error); + event_loop.exit(); + } + + /// Отдать событие интерфейсу. `true` — egui забрал его себе. + fn feed_ui(&mut self, event: &WindowEvent) -> bool { + let Some(window) = self.window.as_ref() else { + return false; + }; + let Some(renderer) = self.renderer.as_mut() else { + return false; + }; + renderer.on_window_event(window, event) + } + + fn draw(&mut self) -> anyhow::Result<()> { + let Some(window) = self.window.as_ref() else { + return Ok(()); + }; + let Some(renderer) = self.renderer.as_mut() else { + return Ok(()); + }; + // Отношение сторон внутри замыкания не спросить — рендерер там уже одолжен, + // но за кадр оно и не меняется. + let aspect = renderer.aspect_ratio(); + + let camera = &mut self.camera; + let editor_ui = &mut self.editor_ui; + let mesh_panel = &mut self.mesh_panel; + + renderer.draw_frame(window, |ctx| { + editor_ui.draw(ctx, camera); + mesh_panel.draw(ctx); + apply_mouse_input(ctx, camera); + FrameState { + view_proj: camera.view_proj(aspect), + render_mode: to_render_mode(editor_ui.display_mode()), + grid_visible: editor_ui.grid_visible(), + } + })?; + + // Готовый меш забирается ПОСЛЕ кадра. В C++ то же самое делалось прямо из + // колбэка панели, то есть из середины `DrawFrame`, уже после захвата образа + // цепочки показа: `SetMeshCpu` там дёргает `vkDeviceWaitIdle` внутри записи + // кадра. Здесь так нельзя (рендерер одолжен), и получилось честнее. + if let Some(mut mesh) = mesh_panel.take_loaded() { + weld_vertices(&mut mesh, WELD_TOLERANCE); + log::info!( + target: "mesh", + "меш готов: {} вершин, {} треугольников", + mesh.vertex_count(), + mesh.triangle_count() + ); + renderer.set_mesh_cpu(&mesh)?; + // Подогнать камеру под размер модели: она перенесена в начало координат. + let radius = 0.5 * (mesh.bbox_max - mesh.bbox_min).length(); + if radius > 0.0 { + camera.set_distance(radius * 2.5); + } + } + Ok(()) + } +} + +impl ApplicationHandler for App { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; // возврат из фона — окно уже есть + } + event_loop.set_control_flow(ControlFlow::Poll); + + let window = match Window::new(event_loop, &WindowDesc::default()) { + Ok(w) => w, + Err(e) => return self.fail(event_loop, anyhow::anyhow!("не создать окно: {e}")), + }; + let renderer = match Renderer::new(&window) { + Ok(r) => r, + Err(e) => { + return self.fail(event_loop, anyhow::anyhow!("не запустить рендерер: {e}")) + } + }; + + let mesh_dir = find_mesh_directory(); + log::info!(target: "core", "каталог моделей: {}", mesh_dir.display()); + self.mesh_panel.set_mesh_directory(mesh_dir); + + self.window = Some(window); + self.renderer = Some(renderer); + log::info!(target: "core", "SimVulcan запущен. Закройте окно, чтобы выйти."); + } + + fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: WindowId, event: WindowEvent) { + self.feed_ui(&event); + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::RedrawRequested => { + if let Err(e) = self.draw() { + self.fail(event_loop, e); + } + } + _ => {} + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(window) = &self.window { + window.request_redraw(); + } + } + + fn exiting(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(renderer) = &self.renderer { + renderer.wait_idle(); + } + } +} + +/// Панель редактора не знает про `simv_vk` (крейт ей недоступен), поэтому разбор +/// делается здесь. Добавите вариант в [`DisplayMode`] — компилятор укажет на этот +/// `match`; в C++ на его месте `static_cast(int)`, который смолчит. +fn to_render_mode(mode: DisplayMode) -> RenderMode { + match mode { + DisplayMode::Solid => RenderMode::Solid, + DisplayMode::Wireframe => RenderMode::Wireframe, + DisplayMode::SolidWire => RenderMode::SolidWire, + } +} + +/// Найти `assets/meshes`, поднимаясь от исполняемого файла, а затем от текущего +/// каталога. +/// +/// C++-версия перебирает пять относительных путей вида `../../../../../assets/meshes` +/// от текущего каталога — то есть работает, только если запускать из каталога бинаря. +/// Здесь отсчёт идёт в первую очередь от самого бинаря, так что запуск из любого места +/// находит модели. Шейдеров этот поиск не касается: они вшиты в исполняемый файл. +fn find_mesh_directory() -> PathBuf { + let mut roots: Vec = Vec::new(); + if let Ok(exe) = std::env::current_exe() { + roots.extend(exe.ancestors().map(PathBuf::from)); + } + if let Ok(cwd) = std::env::current_dir() { + roots.extend(cwd.ancestors().map(PathBuf::from)); + } + for root in roots { + let candidate = root.join("assets").join("meshes"); + if candidate.is_dir() { + return candidate; + } + } + PathBuf::from("assets/meshes") +} diff --git a/rust/crates/simv-app/src/main.rs b/rust/crates/simv-app/src/main.rs new file mode 100644 index 0000000..ff264aa --- /dev/null +++ b/rust/crates/simv-app/src/main.rs @@ -0,0 +1,21 @@ +//! Точка входа редактора SimVulcan — перенос `src/app/main.cpp`. +//! +//! В C++ здесь же собирается вся обвязка редактора: поиск каталога моделей, обработчик +//! загрузки, UI-колбэк. Всё это переехало в [`app::App`], потому что при модели +//! событий winit его методы и есть тело цикла. + +mod app; + +use winit::event_loop::EventLoop; + +fn main() -> anyhow::Result<()> { + simv_core::logger::init(); + + let event_loop = EventLoop::new()?; + let mut app = app::App::default(); + event_loop.run_app(&mut app)?; + + // Ошибка, из-за которой цикл прервался, возвращается сюда: в отличие от + // C++-версии её не приходится ловить `catch (...)` — она пришла значением. + app.into_result() +} diff --git a/rust/crates/simv-core/Cargo.toml b/rust/crates/simv-core/Cargo.toml new file mode 100644 index 0000000..8172405 --- /dev/null +++ b/rust/crates/simv-core/Cargo.toml @@ -0,0 +1,13 @@ +# Аналог цели simv_core: журнал и окно. Vulkan сюда не входит и входить не должен. +[package] +name = "simv-core" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Журнал с категориями и окно — общая основа редактора" + +[dependencies] +log.workspace = true +chrono.workspace = true +winit.workspace = true diff --git a/rust/crates/simv-core/src/lib.rs b/rust/crates/simv-core/src/lib.rs new file mode 100644 index 0000000..67524d4 --- /dev/null +++ b/rust/crates/simv-core/src/lib.rs @@ -0,0 +1,13 @@ +//! Общая основа редактора: журнал и окно. +//! +//! Соответствует цели `simv_core` из CMake, но без `App`: в C++ граф целей цикличен +//! (`core::App` владеет `vk::Renderer`, а `vk::Renderer` принимает `core::Window&`), +//! и держится это лишь на том, что `simv_vk` видит заголовки `core` через +//! `target_include_directories`, не линкуя саму цель. Cargo цикл между крейтами +//! отвергает, поэтому `App` переехал в `simv-app`. Разбор — в `docs/rust_vs_cpp.md`. + +pub mod logger; +pub mod window; + +pub use logger::LogCategory; +pub use window::{Window, WindowDesc}; diff --git a/rust/crates/simv-core/src/logger.rs b/rust/crates/simv-core/src/logger.rs new file mode 100644 index 0000000..d02754f --- /dev/null +++ b/rust/crates/simv-core/src/logger.rs @@ -0,0 +1,230 @@ +//! Журнал с категориями, уровнями и троттлингом — перенос `simv::core::Logger`. +//! +//! Категории C++-версии легли на target'ы крейта `log`, поэтому писать в журнал +//! можно обычными макросами: +//! +//! ```ignore +//! log::info!(target: "vk", "контекст готов: {}", name); +//! ``` +//! +//! Выигрыш против C++-версии, где `LogFmt` знал только про свои вызовы: под тот же +//! фильтр и троттлинг автоматически попадают сообщения зависимостей, которые тоже +//! пишут через `log`. Незнакомый target считается категорией `Core` — так опечатка +//! в имени не выбрасывает сообщение мимо всех настроек. + +use std::io::Write; +use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU8, Ordering}; +use std::sync::{LazyLock, OnceLock}; +use std::time::Instant; + +use log::{Level, LevelFilter, Log, Metadata, Record}; + +/// Категории журнала. Порядок и имена — как в C++-версии. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum LogCategory { + Core, + Vulkan, + MeshIo, + Ui, + Test, +} + +impl LogCategory { + pub const ALL: [LogCategory; 5] = [ + LogCategory::Core, + LogCategory::Vulkan, + LogCategory::MeshIo, + LogCategory::Ui, + LogCategory::Test, + ]; + + /// Строка, которую передают в `target:` у макросов `log`. + pub const fn target(self) -> &'static str { + match self { + LogCategory::Core => "core", + LogCategory::Vulkan => "vk", + LogCategory::MeshIo => "mesh", + LogCategory::Ui => "ui", + LogCategory::Test => "test", + } + } + + const fn index(self) -> usize { + match self { + LogCategory::Core => 0, + LogCategory::Vulkan => 1, + LogCategory::MeshIo => 2, + LogCategory::Ui => 3, + LogCategory::Test => 4, + } + } + + /// Разбор target'а. Крейты-зависимости пишут в свои target'ы («wgpu_hal», «winit» + /// и подобные) — они попадают в `Core` и подчиняются её настройкам. + fn from_target(target: &str) -> LogCategory { + // Зависимости пишут в target'ы вида «egui_winit::state», берём корень. + let root = target.split("::").next().unwrap_or(target); + match root { + "vk" => LogCategory::Vulkan, + "mesh" => LogCategory::MeshIo, + "ui" => LogCategory::Ui, + "test" => LogCategory::Test, + _ => LogCategory::Core, + } + } +} + +/// Настройки одной категории. Меняются в любой момент из любого потока — ровно то, +/// что в C++-версии делали `SetMinLevel` / `SetThrottle` / `SetEnabled`. +struct CategoryState { + enabled: AtomicBool, + /// `LevelFilter as u8`: Off=0, Error=1, … Trace=5. + min_level: AtomicU8, + throttle_ms: AtomicI64, + /// `i64::MIN` — в этой категории ещё ничего не печатали. + last_log_ms: AtomicI64, +} + +impl CategoryState { + const fn new() -> Self { + Self { + enabled: AtomicBool::new(true), + min_level: AtomicU8::new(LevelFilter::Info as u8), + // C++-версия ставит по умолчанию 0.5 с на категорию, но её низкоуровневый + // код Vulkan пишет прямо через spdlog, минуя категории, поэтому стартовые + // сообщения там всё равно печатаются все. Здесь через категории идёт ВСЁ, + // и тот же порог съедал бы половину стартового вывода: имя видеокарты и + // размер кэша конвейеров приходят в пределах одной сотой секунды. + // Поведение приложения важнее буквы: по умолчанию не троттлим, + // `set_throttle` включает порог там, где сообщения идут покадрово. + throttle_ms: AtomicI64::new(0), + last_log_ms: AtomicI64::new(i64::MIN), + } + } +} + +static CATEGORIES: [CategoryState; 5] = [ + CategoryState::new(), + CategoryState::new(), + CategoryState::new(), + CategoryState::new(), + CategoryState::new(), +]; + +static EPOCH: LazyLock = LazyLock::new(Instant::now); +static LOGGER: SimvLogger = SimvLogger; +static INIT: OnceLock<()> = OnceLock::new(); + +fn now_ms() -> i64 { + EPOCH.elapsed().as_millis() as i64 +} + +struct SimvLogger; + +impl Log for SimvLogger { + fn enabled(&self, metadata: &Metadata<'_>) -> bool { + // Дешёвая проверка до форматирования аргументов. Троттлинг сюда не попадает + // намеренно: у него есть побочный эффект, а `enabled` вызывается и просто так. + let state = &CATEGORIES[LogCategory::from_target(metadata.target()).index()]; + state.enabled.load(Ordering::Relaxed) + && (metadata.level() as u8) <= state.min_level.load(Ordering::Relaxed) + } + + fn log(&self, record: &Record<'_>) { + if !self.enabled(record.metadata()) { + return; + } + let category = LogCategory::from_target(record.target()); + let state = &CATEGORIES[category.index()]; + + // Предупреждения и ошибки троттлинг обходят: пропустить их — хуже, чем + // залить ими вывод. + let important = record.level() <= Level::Warn; + if !important { + let throttle = state.throttle_ms.load(Ordering::Relaxed); + if throttle > 0 { + let now = now_ms(); + let last = state.last_log_ms.load(Ordering::Relaxed); + if last != i64::MIN && (now - last) < throttle { + return; + } + state.last_log_ms.store(now, Ordering::Relaxed); + } + } + + let (name, color) = match record.level() { + Level::Error => ("error", "\x1b[31;1m"), + Level::Warn => ("warning", "\x1b[33;1m"), + Level::Info => ("info", "\x1b[32m"), + Level::Debug => ("debug", "\x1b[36m"), + Level::Trace => ("trace", "\x1b[90m"), + }; + let stamp = chrono::Local::now().format("%H:%M:%S%.3f"); + + let mut out = std::io::stdout().lock(); + let _ = writeln!( + out, + "[{stamp}] [{color}{name}\x1b[0m] [{}] {}", + category.target(), + record.args() + ); + } + + fn flush(&self) { + let _ = std::io::stdout().flush(); + } +} + +/// Подключить журнал. Повторные вызовы безвредны — выигрывает первый. +pub fn init() { + INIT.get_or_init(|| { + LazyLock::force(&EPOCH); + // Глобальный порог держим открытым: решение принимает фильтр по категориям. + log::set_max_level(LevelFilter::Trace); + // Ошибка означает, что журнал уже кто-то поставил — ничего страшного. + let _ = log::set_logger(&LOGGER); + }); +} + +/// Порог уровня для категории. +pub fn set_min_level(category: LogCategory, level: LevelFilter) { + CATEGORIES[category.index()] + .min_level + .store(level as u8, Ordering::Relaxed); +} + +/// Минимальный интервал между сообщениями категории, в секундах. `0` — без троттлинга. +pub fn set_throttle(category: LogCategory, seconds: f64) { + CATEGORIES[category.index()] + .throttle_ms + .store((seconds * 1000.0) as i64, Ordering::Relaxed); +} + +/// Полностью выключить или включить категорию. +pub fn set_enabled(category: LogCategory, enabled: bool) { + CATEGORIES[category.index()] + .enabled + .store(enabled, Ordering::Relaxed); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn known_targets_map_to_their_categories() { + assert_eq!(LogCategory::from_target("vk"), LogCategory::Vulkan); + assert_eq!(LogCategory::from_target("mesh"), LogCategory::MeshIo); + // Чужие target'ы зависимостей не теряются, а попадают в Core. + assert_eq!(LogCategory::from_target("winit::platform"), LogCategory::Core); + } + + #[test] + fn every_category_has_a_distinct_index() { + let mut seen = [false; 5]; + for category in LogCategory::ALL { + assert!(!seen[category.index()], "индекс {} занят дважды", category.index()); + seen[category.index()] = true; + } + } +} diff --git a/rust/crates/simv-core/src/window.rs b/rust/crates/simv-core/src/window.rs new file mode 100644 index 0000000..1f3f916 --- /dev/null +++ b/rust/crates/simv-core/src/window.rs @@ -0,0 +1,67 @@ +//! Окно — перенос `simv::core::Window`. +//! +//! Отличие от C++-версии не в обёртке, а в модели событий. У GLFW цикл пишет +//! приложение: `while (!ShouldClose()) { PollEvents(); DrawFrame(); }`. У winit цикл +//! владеет собой сам и зовёт приложение через `ApplicationHandler`, поэтому +//! `ShouldClose` и `PollEvents` здесь просто нечему соответствовать — они переехали +//! в `simv_app::App` в виде обработчиков событий. +//! +//! Заодно исчез `RequiredInstanceExtensions`: в C++ список расширений спрашивали у +//! GLFW, в Rust его даёт `ash_window::enumerate_required_extensions` по дескриптору +//! дисплея, то есть он оказался на стороне графики, а не окна. + +use winit::dpi::LogicalSize; +use winit::error::OsError; +use winit::event_loop::ActiveEventLoop; +use winit::window::{Window as WinitWindow, WindowAttributes}; + +/// Параметры создаваемого окна. +pub struct WindowDesc { + pub title: String, + pub width: u32, + pub height: u32, + pub resizable: bool, +} + +impl Default for WindowDesc { + fn default() -> Self { + Self { + title: "SimVulcan".to_owned(), + width: 1280, + height: 720, + resizable: true, + } + } +} + +/// Окно приложения. +pub struct Window { + handle: WinitWindow, +} + +impl Window { + pub fn new(event_loop: &ActiveEventLoop, desc: &WindowDesc) -> Result { + let attributes = WindowAttributes::default() + .with_title(desc.title.clone()) + .with_inner_size(LogicalSize::new(desc.width, desc.height)) + .with_resizable(desc.resizable); + Ok(Self { + handle: event_loop.create_window(attributes)?, + }) + } + + pub fn raw(&self) -> &WinitWindow { + &self.handle + } + + /// Размер кадрового буфера в пикселях. Ноль по любой оси означает свёрнутое окно — + /// рисовать в этот кадр нельзя. + pub fn framebuffer_size(&self) -> (u32, u32) { + let size = self.handle.inner_size(); + (size.width, size.height) + } + + pub fn request_redraw(&self) { + self.handle.request_redraw(); + } +} diff --git a/rust/crates/simv-editor/Cargo.toml b/rust/crates/simv-editor/Cargo.toml new file mode 100644 index 0000000..0708449 --- /dev/null +++ b/rust/crates/simv-editor/Cargo.toml @@ -0,0 +1,15 @@ +# Аналог цели simv_editor: камера, ввод и панели. Vulkan не видит. +[package] +name = "simv-editor" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Орбитальная камера и панели редактора на egui" + +[dependencies] +simv-core.workspace = true +simv-mesh.workspace = true +egui.workspace = true +glam.workspace = true +log.workspace = true diff --git a/rust/crates/simv-editor/src/camera.rs b/rust/crates/simv-editor/src/camera.rs new file mode 100644 index 0000000..34935bd --- /dev/null +++ b/rust/crates/simv-editor/src/camera.rs @@ -0,0 +1,152 @@ +//! Орбитальная камера — перенос `simv::editor::Camera`. +//! +//! Смотрит всегда в начало координат; yaw/pitch/расстояние ставят глаз на сферу +//! вокруг него. Загруженные модели переносятся в центр матрицей модели, так что +//! отдельной точки интереса у камеры нет. +//! +//! Проекция — в пространстве отсечения Vulkan. В C++ это стоит двух отдельных мер: +//! `#define GLM_FORCE_DEPTH_ZERO_TO_ONE` перед включением заголовка (и надежда, что +//! никакой другой файл не включит glm раньше без него) плюс ручной переворот +//! `p[1][1] *= -1` с комментарием, зачем он. У glam соглашение вынесено в путь +//! функции: `camera::rh::proj::vulkan::perspective` уже даёт и глубину [0, 1], и +//! Y вниз, так что переворачивать вручную нечего. + +use glam::{Mat4, Vec3}; + +const PITCH_LIMIT: f32 = 1.553_34; // ~89°, чтобы не поймать вырождение look_at +const MIN_DISTANCE: f32 = 0.5; +const MAX_DISTANCE: f32 = 200.0; + +/// Плоскость, к которой можно развернуть вид кнопкой. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum ViewPlane { + /// Спереди: взгляд вдоль −Z, плоскость XY. + Front, + /// Сверху: взгляд вдоль −Y, плоскость XZ. + Top, + /// Сбоку: взгляд вдоль −X, плоскость YZ. + Side, +} + +pub struct Camera { + yaw: f32, + pitch: f32, + distance: f32, +} + +impl Default for Camera { + fn default() -> Self { + Self { + yaw: 0.6, + pitch: 0.5, + distance: 8.0, + } + } +} + +impl Camera { + /// Повернуть вокруг цели, в радианах. + pub fn orbit(&mut self, d_yaw: f32, d_pitch: f32) { + self.yaw += d_yaw; + self.pitch = (self.pitch + d_pitch).clamp(-PITCH_LIMIT, PITCH_LIMIT); + } + + /// Приблизить или отдалить. Положительное значение (колесо вверх) приближает. + pub fn zoom(&mut self, delta: f32) { + self.distance = (self.distance * (-delta * 0.15).exp()).clamp(MIN_DISTANCE, MAX_DISTANCE); + } + + pub fn set_distance(&mut self, distance: f32) { + self.distance = distance.clamp(MIN_DISTANCE, MAX_DISTANCE); + } + + pub fn distance(&self) -> f32 { + self.distance + } + + /// Развернуть вид точно на одну из главных плоскостей. + pub fn align_to_plane(&mut self, plane: ViewPlane) { + match plane { + ViewPlane::Front => { + self.yaw = 0.0; + self.pitch = 0.0; + } + ViewPlane::Top => { + self.yaw = 0.0; + self.pitch = PITCH_LIMIT; + } + ViewPlane::Side => { + self.yaw = std::f32::consts::FRAC_PI_2; + self.pitch = 0.0; + } + } + } + + pub fn view(&self) -> Mat4 { + let target = Vec3::ZERO; + let eye = target + + self.distance + * Vec3::new( + self.pitch.cos() * self.yaw.sin(), + self.pitch.sin(), + self.pitch.cos() * self.yaw.cos(), + ); + glam::camera::rh::view::look_at_mat4(eye, target, Vec3::Y) + } + + /// Проекция в пространстве отсечения Vulkan: глубина [0, 1] и Y вниз. + pub fn proj(&self, aspect: f32) -> Mat4 { + glam::camera::rh::proj::vulkan::perspective(45.0_f32.to_radians(), aspect, 0.05, 500.0) + } + + pub fn view_proj(&self, aspect: f32) -> Mat4 { + self.proj(aspect) * self.view() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn pitch_is_clamped_at_the_poles() { + let mut camera = Camera::default(); + camera.orbit(0.0, 100.0); + assert!(camera.view().is_finite(), "матрица вида выродилась на полюсе"); + camera.orbit(0.0, -200.0); + assert!(camera.view().is_finite()); + } + + #[test] + fn zoom_stays_within_limits() { + let mut camera = Camera::default(); + for _ in 0..200 { + camera.zoom(1.0); + } + assert!(camera.distance() >= MIN_DISTANCE); + for _ in 0..200 { + camera.zoom(-1.0); + } + assert!(camera.distance() <= MAX_DISTANCE); + } + + /// Ближняя плоскость обязана лечь в z = 0, дальняя — в z = 1: это и есть + /// пространство отсечения Vulkan, а не OpenGL с диапазоном [−1, 1]. + #[test] + fn projection_uses_vulkan_depth_range() { + let camera = Camera::default(); + let p = camera.proj(16.0 / 9.0); + let near = p * glam::Vec4::new(0.0, 0.0, -0.05, 1.0); + let far = p * glam::Vec4::new(0.0, 0.0, -500.0, 1.0); + assert!((near.z / near.w - 0.0).abs() < 1e-4, "ближняя плоскость: {}", near.z / near.w); + assert!((far.z / far.w - 1.0).abs() < 1e-4, "дальняя плоскость: {}", far.z / far.w); + } + + /// Переворот Y обязателен: без него картинка встанет вверх ногами. + #[test] + fn projection_flips_y_for_vulkan() { + let camera = Camera::default(); + let p = camera.proj(1.0); + assert!(p.y_axis.y < 0.0); + } +} diff --git a/rust/crates/simv-editor/src/camera_input.rs b/rust/crates/simv-editor/src/camera_input.rs new file mode 100644 index 0000000..d4f55ab --- /dev/null +++ b/rust/crates/simv-editor/src/camera_input.rs @@ -0,0 +1,30 @@ +//! Мышь для камеры — перенос `simv::editor::CameraInput`. + +use crate::camera::Camera; + +/// Радиан на пиксель перетаскивания — как в C++-версии. +const ORBIT_SPEED: f32 = 0.01; + +/// Множитель колеса. +/// +/// В ImGui `io.MouseWheel` считает «щелчки»: одна прокрутка — ±1, и C++-версия берёт +/// половину. egui отдаёт прокрутку в точках, и один щелчок — это примерно пятьдесят. +/// Отсюда сотая доля: ощущение от колеса остаётся тем же. +const ZOOM_SPEED: f32 = 0.01; + +/// Применить ввод мыши к камере. Ввод, который забрал интерфейс, до сцены не доходит. +pub fn apply_mouse_input(ctx: &egui::Context, camera: &mut Camera) { + if ctx.egui_wants_pointer_input() { + return; // указатель над панелью + } + ctx.input(|input| { + if input.pointer.primary_down() { + let delta = input.pointer.delta(); + camera.orbit(delta.x * ORBIT_SPEED, -delta.y * ORBIT_SPEED); + } + let wheel = input.smooth_scroll_delta.y; + if wheel != 0.0 { + camera.zoom(wheel * ZOOM_SPEED); + } + }); +} diff --git a/rust/crates/simv-editor/src/lib.rs b/rust/crates/simv-editor/src/lib.rs new file mode 100644 index 0000000..cbba6f7 --- /dev/null +++ b/rust/crates/simv-editor/src/lib.rs @@ -0,0 +1,13 @@ +//! Камера, ввод и панели редактора — соответствует цели `simv_editor`. +//! +//! Vulkan отсюда не виден: у крейта нет ни `ash`, ни `simv-vk` в зависимостях. + +pub mod camera; +pub mod camera_input; +pub mod mesh_load_panel; +pub mod ui; + +pub use camera::{Camera, ViewPlane}; +pub use camera_input::apply_mouse_input; +pub use mesh_load_panel::MeshLoadPanel; +pub use ui::{DisplayMode, EditorUi}; diff --git a/rust/crates/simv-editor/src/mesh_load_panel.rs b/rust/crates/simv-editor/src/mesh_load_panel.rs new file mode 100644 index 0000000..4ef5d54 --- /dev/null +++ b/rust/crates/simv-editor/src/mesh_load_panel.rs @@ -0,0 +1,156 @@ +//! Панель загрузки моделей — перенос `simv::editor::MeshLoadPanel`. +//! +//! Расхождение с C++-версией — в том, как результат попадает наружу. Там панель +//! держит `std::function` и зовёт его прямо из `Draw`, то есть из +//! середины `Renderer::DrawFrame`, уже после `vkAcquireNextImageKHR`. Обработчик в +//! `main.cpp` из этого колбэка дёргает `renderer.SetMeshCpu`, а тот — `vkDeviceWaitIdle` +//! и выгрузку на видеокарту. Ждать простоя устройства посреди записи кадра законно, +//! но сомнительно. +//! +//! В Rust так просто не выйдет: замыкание, трогающее рендерер, вызывается из метода +//! этого же рендерера, то есть рендерер одолжен дважды. Панель вместо колбэка +//! складывает готовый меш в поле, а приложение забирает его через +//! [`MeshLoadPanel::take_loaded`] после того, как кадр закончен. Ограничение языка +//! здесь заодно вынесло выгрузку из середины кадра. + +use std::path::{Path, PathBuf}; + +use simv_mesh::{load_obj_async, Mesh, PendingLoad}; + +pub struct MeshLoadPanel { + mesh_dir: PathBuf, + obj_files: Vec, + selected: Option, + status: String, + pending: Option, + loaded: Option, + open: bool, +} + +impl Default for MeshLoadPanel { + fn default() -> Self { + let mut panel = Self { + mesh_dir: PathBuf::from("assets/meshes"), + obj_files: Vec::new(), + selected: None, + status: String::new(), + pending: None, + loaded: None, + open: true, + }; + panel.rescan_files(); + panel + } +} + +impl MeshLoadPanel { + pub fn set_mesh_directory(&mut self, dir: PathBuf) { + self.mesh_dir = dir; + self.selected = None; + self.rescan_files(); + } + + /// Забрать загруженный меш, если он готов. Вызывать после кадра. + pub fn take_loaded(&mut self) -> Option { + self.loaded.take() + } + + fn rescan_files(&mut self) { + self.obj_files.clear(); + let Ok(entries) = std::fs::read_dir(&self.mesh_dir) else { + return; + }; + for entry in entries.flatten() { + if !entry.file_type().is_ok_and(|t| t.is_file()) { + continue; + } + let path = entry.path(); + // Расширение без учёта регистра: `Cube.obj`, `cube.OBJ`, `Plane.Obj`. + let is_obj = path + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e.eq_ignore_ascii_case("obj")); + if is_obj { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) { + self.obj_files.push(name.to_owned()); + } + } + } + self.obj_files.sort(); + } + + pub fn draw(&mut self, ctx: &egui::Context) { + if !self.open { + return; + } + + // Опрос незавершённой загрузки на главном потоке. + if let Some(pending) = &mut self.pending { + if let Some(result) = pending.try_take() { + self.pending = None; + match result { + Ok(mesh) => { + self.loaded = Some(mesh); + self.status = String::from("Загружено."); + } + Err(e) => self.status = format!("Ошибка: {e}"), + } + } + } + + let mut open = self.open; + egui::Window::new("Модель") + .open(&mut open) + .default_size([360.0, 240.0]) + .show(ctx, |ui| self.contents(ui)); + self.open = open; + } + + fn contents(&mut self, ui: &mut egui::Ui) { + ui.label(egui::RichText::new(format!("Каталог: {}", self.mesh_dir.display())).weak()); + if ui.small_button("Обновить список").clicked() { + self.rescan_files(); + } + ui.separator(); + + egui::ScrollArea::vertical() + .max_height(120.0) + .auto_shrink([false, false]) + .show(ui, |ui| { + for (index, name) in self.obj_files.iter().enumerate() { + if ui + .selectable_label(self.selected == Some(index), name) + .clicked() + { + self.selected = Some(index); + } + } + }); + + let busy = self.pending.is_some(); + let can_load = self.selected.is_some() && !busy; + ui.horizontal(|ui| { + if ui + .add_enabled(can_load, egui::Button::new("Загрузить")) + .clicked() + { + if let Some(index) = self.selected { + self.status = String::from("Загружаю…"); + self.pending = Some(load_obj_async(self.mesh_dir.join(&self.obj_files[index]))); + } + } + if busy { + ui.label(egui::RichText::new("(идёт загрузка…)").weak()); + } + }); + + if !self.status.is_empty() { + ui.separator(); + ui.label(&self.status); + } + } + + pub fn mesh_directory(&self) -> &Path { + &self.mesh_dir + } +} diff --git a/rust/crates/simv-editor/src/ui.rs b/rust/crates/simv-editor/src/ui.rs new file mode 100644 index 0000000..93293c8 --- /dev/null +++ b/rust/crates/simv-editor/src/ui.rs @@ -0,0 +1,80 @@ +//! Панель вьюпорта — перенос `simv::editor::EditorUI`. +//! +//! Про [`DisplayMode`]. Панель обязана как-то сообщить рендереру выбранный режим, но +//! `simv_vk::RenderMode` ей недоступен: `simv-editor` не зависит от `simv-vk`, и это +//! проверяет компилятор. C++ обходит ту же границу, возвращая из `EditorUI` голый +//! `int`, который `main.cpp` приводит через `static_cast` к `vk::RenderMode` — +//! связь между двумя перечислениями там держится на честном слове. Здесь у панели +//! свой типизированный режим, а разбор в `simv_vk::RenderMode` делает `simv-app` +//! обычным `match`: добавите вариант — компилятор укажет, где его дописать. + +use crate::camera::{Camera, ViewPlane}; + +/// Как отображать модель. Соответствует `simv_vk::RenderMode`, но живёт по эту +/// сторону границы модулей. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum DisplayMode { + #[default] + Solid, + Wireframe, + SolidWire, +} + +pub struct EditorUi { + display_mode: DisplayMode, + grid_visible: bool, +} + +impl Default for EditorUi { + fn default() -> Self { + Self { + display_mode: DisplayMode::Solid, + grid_visible: true, + } + } +} + +impl EditorUi { + pub fn display_mode(&self) -> DisplayMode { + self.display_mode + } + + pub fn grid_visible(&self) -> bool { + self.grid_visible + } + + pub fn draw(&mut self, ctx: &egui::Context, camera: &mut Camera) { + egui::Window::new("Viewport") + .default_size([280.0, 200.0]) + .show(ctx, |ui| { + ui.label(egui::RichText::new("Камера").weak()); + ui.horizontal(|ui| { + if ui.button("Спереди").clicked() { + camera.align_to_plane(ViewPlane::Front); + } + if ui.button("Сверху").clicked() { + camera.align_to_plane(ViewPlane::Top); + } + if ui.button("Сбоку").clicked() { + camera.align_to_plane(ViewPlane::Side); + } + }); + ui.label( + egui::RichText::new("Перетаскивание — поворот, колесо — приближение").weak(), + ); + + ui.separator(); + ui.label(egui::RichText::new("Отображение модели").weak()); + ui.radio_value(&mut self.display_mode, DisplayMode::Solid, "Заливка"); + ui.radio_value(&mut self.display_mode, DisplayMode::Wireframe, "Каркас"); + ui.radio_value( + &mut self.display_mode, + DisplayMode::SolidWire, + "Заливка с каркасом", + ); + + ui.separator(); + ui.checkbox(&mut self.grid_visible, "Показывать сетку"); + }); + } +} diff --git a/rust/crates/simv-mesh/Cargo.toml b/rust/crates/simv-mesh/Cargo.toml new file mode 100644 index 0000000..80e2f6e --- /dev/null +++ b/rust/crates/simv-mesh/Cargo.toml @@ -0,0 +1,16 @@ +# Аналог цели simv_mesh: меш на стороне процессора. Ни ash, ни egui в зависимостях — +# граница «Vulkan только в simv-vk» держится не дисциплиной, а графом крейтов. +[package] +name = "simv-mesh" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Загрузка OBJ и сварка вершин, без графического API" + +[dependencies] +simv-core.workspace = true +glam.workspace = true +tobj.workspace = true +log.workspace = true +thiserror.workspace = true diff --git a/rust/crates/simv-mesh/src/decimator.rs b/rust/crates/simv-mesh/src/decimator.rs new file mode 100644 index 0000000..d447fe6 --- /dev/null +++ b/rust/crates/simv-mesh/src/decimator.rs @@ -0,0 +1,169 @@ +//! Сварка близких вершин — перенос `simv::mesh::WeldVertices`. +//! +//! Имя файла в C++-версии (`MeshDecimator`) обещает больше, чем есть: никакого LOD и +//! прореживания здесь нет, только схлопывание дубликатов по пространственному хешу и +//! удаление выродившихся треугольников. Название сохранено, чтобы модули двух версий +//! сопоставлялись один к одному. + +use std::collections::HashMap; +use std::hash::{BuildHasherDefault, Hasher}; + +use glam::Vec3; + +use crate::mesh::Mesh; + +/// Ключ ячейки пространственного хеша. +type CellKey = [i32; 3]; + +/// Тот же FNV-1a, что в C++-версии. +/// +/// На результат сварки хеш-функция не влияет — карта точная, ключи целочисленные, — +/// но брать стандартный SipHash значило бы сравнивать не алгоритмы, а хеши: SipHash +/// криптостойкий и заметно дороже. FNV перенесён ровно ради того, чтобы замер времени +/// сварки в отчёте мерил одно и то же в обеих версиях. +#[derive(Default)] +struct Fnv1a(u64); + +impl Hasher for Fnv1a { + fn finish(&self) -> u64 { + self.0 + } + + fn write(&mut self, bytes: &[u8]) { + // Ключ приходит четвёрками байт (три i32), мешаем по 32-битным словам — + // так же, как C++-версия мешает три int32. + let mut h = if self.0 == 0 { 0xcbf2_9ce4_8422_2325 } else { self.0 }; + for word in bytes.chunks(4) { + let mut v = [0u8; 4]; + v[..word.len()].copy_from_slice(word); + h ^= u32::from_ne_bytes(v) as u64; + h = h.wrapping_mul(0x0100_0000_01b3); + } + self.0 = h; + } +} + +type FnvMap = HashMap>; + +/// Группировка округлением к ближайшему узлу решётки. +/// +/// Две точки, отстоящие меньше чем на `tolerance`, гарантированно попадают в одну +/// ячейку, если обе лежат в пределах допуска от общего узла. Бакетирование через +/// `floor` на границах ячеек ломается, когда числа падают по разные стороны, — +/// округление и есть стандартное лекарство для сварки по пространственному хешу. +#[inline] +fn to_cell(p: Vec3, tolerance: f32) -> CellKey { + let t = tolerance as f64; + [ + (p.x as f64 / t).round() as i32, + (p.y as f64 / t).round() as i32, + (p.z as f64 / t).round() as i32, + ] +} + +/// Схлопнуть вершины, отстоящие меньше чем на `tolerance`, переписать индексы и +/// выбросить треугольники, у которых после этого совпали хотя бы две вершины. +pub fn weld_vertices(mesh: &mut Mesh, tolerance: f32) { + if mesh.positions.is_empty() || tolerance <= 0.0 { + return; + } + + let mut cell_to_vertex: FnvMap = + FnvMap::with_capacity_and_hasher(mesh.positions.len(), BuildHasherDefault::default()); + let mut welded: Vec = Vec::with_capacity(mesh.positions.len()); + let mut remap: Vec = Vec::with_capacity(mesh.positions.len()); + + for &p in &mesh.positions { + let cell = to_cell(p, tolerance); + let index = *cell_to_vertex.entry(cell).or_insert_with(|| { + welded.push(p); + (welded.len() - 1) as u32 + }); + remap.push(index); + } + + let mut new_indices = Vec::with_capacity(mesh.indices.len()); + for tri in mesh.indices.chunks_exact(3) { + let a = remap[tri[0] as usize]; + let b = remap[tri[1] as usize]; + let c = remap[tri[2] as usize]; + if a == b || b == c || a == c { + continue; + } + new_indices.extend_from_slice(&[a, b, c]); + } + + mesh.positions = welded; + mesh.indices = new_indices; + mesh.recalculate_bounds(); +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Перенос `TEST_CASE("WeldVertices collapses near-duplicate vertices")`. + #[test] + fn weld_collapses_near_duplicate_vertices() { + let mut m = Mesh { + // Два треугольника с общим ребром; вершины разведены в пределах допуска. + positions: vec![ + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Vec3::new(0.0001, 0.0, 0.0), // ≈ #0 + Vec3::new(1.0001, 0.0, 0.0), // ≈ #1 + Vec3::new(1.0, 1.0, 0.0), + ], + indices: vec![0, 1, 2, 3, 4, 5], + ..Default::default() + }; + + weld_vertices(&mut m, 0.01); + + assert_eq!(m.positions.len(), 4); + assert_eq!(m.indices.len(), 6, "оба треугольника обязаны выжить"); + // Габариты должны совпасть с «настоящей» оболочкой независимо от порядка сварки. + assert!((m.bbox_max.x - 1.0001).abs() < 1e-3); + } + + /// Перенос `TEST_CASE("WeldVertices drops degenerate triangles")`. + #[test] + fn weld_drops_degenerate_triangles() { + let mut m = Mesh { + // Один настоящий треугольник и один вырожденный: все три вершины в допуске. + positions: vec![ + Vec3::new(0.0, 0.0, 0.0), + Vec3::new(1.0, 0.0, 0.0), + Vec3::new(0.0, 1.0, 0.0), + Vec3::new(2.0, 2.0, 0.0), + Vec3::new(2.00005, 2.0, 0.0), + Vec3::new(2.0, 2.00005, 0.0), + ], + indices: vec![0, 1, 2, 3, 4, 5], + ..Default::default() + }; + + weld_vertices(&mut m, 0.001); + + assert_eq!(m.indices.len(), 3, "вырожденный треугольник обязан исчезнуть"); + } + + /// Округление, а не отсечение: точки по разные стороны границы ячейки, но в + /// пределах допуска друг от друга, всё равно должны слиться. + #[test] + fn weld_buckets_by_rounding_not_flooring() { + let mut m = Mesh { + positions: vec![ + Vec3::new(0.00999, 0.0, 0.0), + Vec3::new(0.01001, 0.0, 0.0), + Vec3::new(5.0, 0.0, 0.0), + ], + indices: vec![0, 1, 2], + ..Default::default() + }; + weld_vertices(&mut m, 0.01); + assert_eq!(m.positions.len(), 2); + assert!(m.indices.is_empty(), "треугольник выродился и должен быть удалён"); + } +} diff --git a/rust/crates/simv-mesh/src/lib.rs b/rust/crates/simv-mesh/src/lib.rs new file mode 100644 index 0000000..8adc8d5 --- /dev/null +++ b/rust/crates/simv-mesh/src/lib.rs @@ -0,0 +1,13 @@ +//! Меш на стороне процессора: чтение `.obj`, сварка вершин, габариты. +//! +//! Соответствует цели `simv_mesh`. Ни `ash`, ни `egui` в зависимостях крейта нет — +//! в C++ границу «Vulkan только в `src/vk/`» держит дисциплина, здесь её держит +//! граф зависимостей: нарушение просто не соберётся. + +pub mod decimator; +pub mod mesh; +pub mod obj_loader; + +pub use decimator::weld_vertices; +pub use mesh::Mesh; +pub use obj_loader::{load_obj, load_obj_async, ObjError, PendingLoad}; diff --git a/rust/crates/simv-mesh/src/mesh.rs b/rust/crates/simv-mesh/src/mesh.rs new file mode 100644 index 0000000..0ce4090 --- /dev/null +++ b/rust/crates/simv-mesh/src/mesh.rs @@ -0,0 +1,77 @@ +//! Меш на стороне процессора — перенос `simv::mesh::Mesh`. + +use glam::Vec3; + +/// Суп из треугольников: позиции и индексы по три на треугольник. +/// Загружается `obj_loader`, при желании прореживается `decimator`, затем уезжает в +/// `simv_vk::GpuMesh`. Никакого графического API здесь нет и быть не должно. +#[derive(Clone, Debug, Default)] +pub struct Mesh { + pub positions: Vec, + pub indices: Vec, + pub bbox_min: Vec3, + pub bbox_max: Vec3, +} + +impl Mesh { + pub fn vertex_count(&self) -> usize { + self.positions.len() + } + + pub fn triangle_count(&self) -> usize { + self.indices.len() / 3 + } + + /// Пуст в смысле «рисовать нечего»: одних вершин без индексов недостаточно. + pub fn is_empty(&self) -> bool { + self.positions.is_empty() || self.indices.is_empty() + } + + /// Пересчитать габаритный ящик. У пустого меша он схлопывается в начало координат — + /// так же, как в C++-версии, чтобы камера не уезжала в бесконечность. + pub fn recalculate_bounds(&mut self) { + if self.positions.is_empty() { + self.bbox_min = Vec3::ZERO; + self.bbox_max = Vec3::ZERO; + return; + } + let mut lo = Vec3::splat(f32::MAX); + let mut hi = Vec3::splat(f32::MIN); + for p in &self.positions { + lo = lo.min(*p); + hi = hi.max(*p); + } + self.bbox_min = lo; + self.bbox_max = hi; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Перенос `TEST_CASE("Mesh::RecalculateBounds finds AABB")`. + #[test] + fn recalculate_bounds_finds_aabb() { + let mut m = Mesh { + positions: vec![ + Vec3::new(-1.0, -2.0, -3.0), + Vec3::new(4.0, 5.0, 6.0), + Vec3::new(0.5, 0.0, 1.0), + ], + ..Default::default() + }; + m.recalculate_bounds(); + assert_eq!(m.bbox_min, Vec3::new(-1.0, -2.0, -3.0)); + assert_eq!(m.bbox_max, Vec3::new(4.0, 5.0, 6.0)); + } + + #[test] + fn empty_mesh_has_degenerate_bounds() { + let mut m = Mesh::default(); + m.recalculate_bounds(); + assert_eq!(m.bbox_min, Vec3::ZERO); + assert_eq!(m.bbox_max, Vec3::ZERO); + assert!(m.is_empty()); + } +} diff --git a/rust/crates/simv-mesh/src/obj_loader.rs b/rust/crates/simv-mesh/src/obj_loader.rs new file mode 100644 index 0000000..2549691 --- /dev/null +++ b/rust/crates/simv-mesh/src/obj_loader.rs @@ -0,0 +1,153 @@ +//! Загрузка `.obj` — перенос `simv::mesh::ObjLoader`. +//! +//! Асинхронный путь устроен иначе, чем в C++. Там `LoadObjAsync` возвращал +//! `std::future`, и исключение из рабочего потока всплывало на главном при +//! вызове `get()`. Здесь поток посылает `Result` по каналу, а вызывающий разбирает +//! его обычным `match` — ошибка стала значением, и потерять её молча уже не выйдет. + +use std::path::{Path, PathBuf}; +use std::sync::mpsc::{self, Receiver, TryRecvError}; + +use glam::Vec3; + +use crate::mesh::Mesh; + +#[derive(Debug, thiserror::Error)] +pub enum ObjError { + #[error("не удалось разобрать {path}: {source}")] + Parse { + path: String, + #[source] + source: tobj::LoadError, + }, + /// Единственный способ, которым канал может закрыться без ответа, — паника в + /// рабочем потоке. Отдельный вариант нужен, чтобы панель показала это как ошибку, + /// а не висела в состоянии «грузится» вечно. + #[error("поток загрузки завершился аварийно")] + WorkerDied, +} + +/// Синхронная загрузка: маленькие файлы, тесты. +/// +/// Расхождение с C++-версией. tinyobjloader отдаёт единый глобальный массив вершин +/// (`attrib.vertices`), в который индексируют все формы, — C++-код так его и берёт. +/// tobj режет файл на модели (по объектам и по материалам) и каждой выдаёт свой +/// массив позиций, поэтому здесь модели склеиваются со сдвигом индексов. Вершины, +/// общие для соседних моделей, при этом задваиваются: до сварки счётчик вершин будет +/// больше, чем в C++-версии. `weld_vertices` их схлопывает, так что после сварки — +/// той самой, которую делает приложение сразу после загрузки, — счётчики совпадают. +pub fn load_obj(path: &Path) -> Result { + let options = tobj::LoadOptions { + triangulate: true, + ignore_points: true, + ignore_lines: true, + ..Default::default() + }; + + let (models, _materials) = tobj::load_obj(path, &options).map_err(|source| ObjError::Parse { + path: path.display().to_string(), + source, + })?; + + let mut mesh = Mesh::default(); + mesh.positions + .reserve(models.iter().map(|m| m.mesh.positions.len() / 3).sum()); + mesh.indices + .reserve(models.iter().map(|m| m.mesh.indices.len()).sum()); + + for model in &models { + let base = mesh.positions.len() as u32; + for xyz in model.mesh.positions.chunks_exact(3) { + mesh.positions.push(Vec3::new(xyz[0], xyz[1], xyz[2])); + } + mesh.indices + .extend(model.mesh.indices.iter().map(|i| i + base)); + } + + mesh.recalculate_bounds(); + + log::info!( + target: "mesh", + "загружен OBJ '{}': {} вершин, {} треугольников", + path.file_name().unwrap_or(path.as_os_str()).to_string_lossy(), + mesh.vertex_count(), + mesh.triangle_count() + ); + Ok(mesh) +} + +/// Незавершённая загрузка. Опрашивается на главном потоке — так же, как C++-версия +/// опрашивала `future` через `wait_for(0ms)`, чтобы обратный вызов (а с ним и выгрузка +/// на видеокарту) выполнялся на потоке рендера. +pub struct PendingLoad { + receiver: Receiver>, +} + +impl PendingLoad { + /// `None` — ещё грузится. `Some(..)` — результат готов; повторные вызовы после + /// этого снова вернут `Err(WorkerDied)`, поэтому забирать надо один раз. + pub fn try_take(&mut self) -> Option> { + match self.receiver.try_recv() { + Ok(result) => Some(result), + Err(TryRecvError::Empty) => None, + Err(TryRecvError::Disconnected) => Some(Err(ObjError::WorkerDied)), + } + } +} + +/// Запустить загрузку в отдельном потоке. +pub fn load_obj_async(path: PathBuf) -> PendingLoad { + let (sender, receiver) = mpsc::channel(); + std::thread::spawn(move || { + // Получатель мог уйти вместе с закрытой панелью — это не ошибка. + let _ = sender.send(load_obj(&path)); + }); + PendingLoad { receiver } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decimator::weld_vertices; + + fn asset(name: &str) -> PathBuf { + // crates/simv-mesh → корень репозитория. + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../../assets/meshes") + .join(name) + } + + #[test] + fn cube_loads_with_twelve_triangles() { + let path = asset("Cube.obj"); + let mut mesh = load_obj(&path).expect("Cube.obj обязан читаться"); + assert_eq!(mesh.triangle_count(), 12); + weld_vertices(&mut mesh, 1e-4); + // Куб после сварки — ровно восемь углов и те же двенадцать треугольников. + assert_eq!(mesh.vertex_count(), 8); + assert_eq!(mesh.triangle_count(), 12); + } + + /// Диагностика для сверки с C++-версией: печатает счётчики до и после сварки. + /// Запуск: cargo test -p simv-mesh -- --ignored --nocapture weld_counts + #[test] + #[ignore = "диагностика, не проверка"] + fn weld_counts_for_bundled_assets() { + for name in ["Cube.obj", "Cube1.obj", "cow.obj", "plane.obj"] { + let mut mesh = load_obj(&asset(name)).expect(name); + let (v0, t0) = (mesh.vertex_count(), mesh.triangle_count()); + weld_vertices(&mut mesh, 1e-4); + println!( + "{name}: до сварки {v0} вершин / {t0} треугольников, после {} / {}", + mesh.vertex_count(), + mesh.triangle_count() + ); + } + } + + #[test] + fn missing_file_reports_error_not_panic() { + let err = load_obj(Path::new("нет-такого-файла.obj")).unwrap_err(); + assert!(matches!(err, ObjError::Parse { .. })); + } +} diff --git a/rust/crates/simv-vk/Cargo.toml b/rust/crates/simv-vk/Cargo.toml new file mode 100644 index 0000000..2523ef7 --- /dev/null +++ b/rust/crates/simv-vk/Cargo.toml @@ -0,0 +1,26 @@ +# Аналог цели simv_vk: единственный крейт, которому позволено видеть ash. +# build.rs компилирует общие с C++-версией шейдеры из ../../shaders в SPIR-V. +[package] +name = "simv-vk" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Весь Vulkan: контекст, swapchain, конвейеры, кадр" +build = "build.rs" + +[dependencies] +simv-core.workspace = true +simv-mesh.workspace = true +ash.workspace = true +ash-window.workspace = true +raw-window-handle.workspace = true +gpu-allocator.workspace = true +egui.workspace = true +egui-winit.workspace = true +egui-ash-renderer.workspace = true +winit.workspace = true +glam.workspace = true +bytemuck.workspace = true +log.workspace = true +thiserror.workspace = true diff --git a/rust/crates/simv-vk/build.rs b/rust/crates/simv-vk/build.rs new file mode 100644 index 0000000..8c4579f --- /dev/null +++ b/rust/crates/simv-vk/build.rs @@ -0,0 +1,95 @@ +//! Компиляция GLSL в SPIR-V на этапе сборки — аналог `cmake/ShaderCompile.cmake`. +//! +//! Шейдеры берутся из общего с C++-версией каталога `shaders/` в корне репозитория: +//! обе сборки компилируют одни и те же файлы теми же ключами, поэтому расхождение в +//! картинке нельзя списать на шейдер. +//! +//! В отличие от CMake результат не копируется рядом с исполняемым файлом, а зашивается +//! в бинарь через `include_bytes!`. Отсюда исчезают и `FindSpvPath`, и POST_BUILD-копия, +//! и требование «запускать из каталога исполняемого файла». + +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn main() { + let manifest = PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").unwrap()); + let out_dir = PathBuf::from(std::env::var("OUT_DIR").unwrap()); + // crates/simv-vk → rust → корень репозитория. + let shader_root = manifest.join("../../../shaders"); + let shader_root = shader_root.canonicalize().unwrap_or(shader_root); + let editor_dir = shader_root.join("editor"); + + println!("cargo:rerun-if-env-changed=VULKAN_SDK"); + println!("cargo:rerun-if-changed={}", editor_dir.display()); + + let compiler = find_glslang(); + + let mut sources: Vec = std::fs::read_dir(&editor_dir) + .unwrap_or_else(|e| panic!("не читается каталог шейдеров {}: {e}", editor_dir.display())) + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter(|p| { + matches!( + p.extension().and_then(|e| e.to_str()), + Some("vert") | Some("frag") + ) + }) + .collect(); + // Порядок обхода каталога зависит от файловой системы — сортируем, чтобы сборка + // была воспроизводимой. + sources.sort(); + + assert!( + !sources.is_empty(), + "в {} нет ни одного .vert/.frag", + editor_dir.display() + ); + + let out_editor = out_dir.join("editor"); + std::fs::create_dir_all(&out_editor).unwrap(); + + for src in &sources { + println!("cargo:rerun-if-changed={}", src.display()); + let name = src.file_name().unwrap().to_string_lossy().to_string(); + let dst = out_editor.join(format!("{name}.spv")); + + let output = Command::new(&compiler) + .arg("-V") + .args(["--target-env", "vulkan1.3"]) + // glslangValidator требует -I<путь> без пробела. + .arg(format!("-I{}", shader_root.display())) + .arg("-o") + .arg(&dst) + .arg(src) + .output() + .unwrap_or_else(|e| panic!("не запускается {}: {e}", compiler.display())); + + if !output.status.success() { + panic!( + "GLSL → SPIR-V не удалось для {}:\n{}{}", + src.display(), + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + } + } +} + +/// Ищем компилятор там же, где его ищет CMake: сначала в Vulkan SDK, потом в PATH. +fn find_glslang() -> PathBuf { + let exe = if cfg!(windows) { + "glslangValidator.exe" + } else { + "glslangValidator" + }; + if let Ok(sdk) = std::env::var("VULKAN_SDK") { + let candidate = Path::new(&sdk).join("Bin").join(exe); + if candidate.exists() { + return candidate; + } + let candidate = Path::new(&sdk).join("bin").join(exe); + if candidate.exists() { + return candidate; + } + } + PathBuf::from(exe) +} diff --git a/rust/crates/simv-vk/src/buffer.rs b/rust/crates/simv-vk/src/buffer.rs new file mode 100644 index 0000000..0c8461a --- /dev/null +++ b/rust/crates/simv-vk/src/buffer.rs @@ -0,0 +1,141 @@ +//! Буфер с памятью из аллокатора — перенос `simv::vk::Buffer`. +//! +//! Расхождение с C++-версией. Там `vk::Buffer` есть, но им не пользуется никто: +//! `GridRenderer`, `GpuMesh` и глубинное вложение `Renderer` создают ресурсы напрямую +//! вызовами VMA, а сама обёртка так и лежит в цели `simv_vk` мёртвым грузом. Здесь она +//! задействована всеми тремя, потому что порт писался, когда потребители уже известны, +//! а с gpu-allocator обёртка — самый короткий путь: она же и держит `Allocation`, +//! который иначе пришлось бы освобождать руками в каждом `Drop`. + +use std::sync::{Arc, Mutex}; + +use ash::vk; +use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator}; + +use crate::context::Context; +use crate::error::{Ctx, Result, VkError}; + +/// Где живёт память буфера. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum MemoryLocation { + /// Только для видеокарты: быстрая, недоступна процессору. + DeviceLocal, + /// Видима процессору, пишется последовательно (загрузка на видеокарту). + HostVisible, + /// Видима процессору, читается в произвольном порядке (чтение с видеокарты). + HostReadback, +} + +impl From for gpu_allocator::MemoryLocation { + fn from(value: MemoryLocation) -> Self { + match value { + MemoryLocation::DeviceLocal => gpu_allocator::MemoryLocation::GpuOnly, + MemoryLocation::HostVisible => gpu_allocator::MemoryLocation::CpuToGpu, + MemoryLocation::HostReadback => gpu_allocator::MemoryLocation::GpuToCpu, + } + } +} + +pub struct Buffer { + device: ash::Device, + allocator: Arc>, + handle: vk::Buffer, + /// `Option` ради `Drop`: память возвращается аллокатору по значению. + allocation: Option, + size: vk::DeviceSize, +} + +impl Buffer { + pub fn new( + ctx: &Context, + name: &str, + size: vk::DeviceSize, + usage: vk::BufferUsageFlags, + location: MemoryLocation, + ) -> Result { + let device = ctx.device().clone(); + let ci = vk::BufferCreateInfo::default() + .size(size) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let handle = unsafe { device.create_buffer(&ci, None) }.ctx("vkCreateBuffer")?; + let requirements = unsafe { device.get_buffer_memory_requirements(handle) }; + + let allocator = ctx.allocator().clone(); + let allocation = { + let mut guard = allocator.lock().expect("аллокатор отравлен паникой"); + guard.allocate(&AllocationCreateDesc { + name, + requirements, + location: location.into(), + linear: true, + allocation_scheme: AllocationScheme::GpuAllocatorManaged, + }) + }; + let allocation = match allocation { + Ok(a) => a, + Err(e) => { + unsafe { device.destroy_buffer(handle, None) }; + return Err(VkError::Other(format!("gpu-allocator: {e}"))); + } + }; + + unsafe { device.bind_buffer_memory(handle, allocation.memory(), allocation.offset()) } + .ctx("vkBindBufferMemory")?; + + Ok(Self { + device, + allocator, + handle, + allocation: Some(allocation), + size, + }) + } + + pub fn handle(&self) -> vk::Buffer { + self.handle + } + pub fn size(&self) -> vk::DeviceSize { + self.size + } + + /// Постоянно отображённая память, если она вообще видима процессору. + /// + /// В отличие от C++-версии здесь нет пары `Map`/`Unmap`: gpu-allocator держит + /// отображение всё время жизни выделения, а срез с временем жизни буфера не даёт + /// уронить указатель раньше самого буфера. + pub fn mapped(&mut self) -> Option<&mut [u8]> { + self.allocation.as_mut()?.mapped_slice_mut() + } + + /// Записать байты по смещению. Возвращает ошибку, если память недоступна + /// процессору или запись не помещается. + pub fn write(&mut self, src: &[u8], offset: usize) -> Result<()> { + let size = self.size as usize; + let Some(dst) = self.mapped() else { + return Err(VkError::Other(String::from( + "запись в буфер, недоступный процессору", + ))); + }; + if offset + src.len() > size { + return Err(VkError::Other(format!( + "запись {} байт по смещению {offset} не помещается в буфер {size} байт", + src.len() + ))); + } + dst[offset..offset + src.len()].copy_from_slice(src); + Ok(()) + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + if let Some(allocation) = self.allocation.take() { + let _ = self + .allocator + .lock() + .map(|mut guard| guard.free(allocation)); + } + unsafe { self.device.destroy_buffer(self.handle, None) }; + } +} diff --git a/rust/crates/simv-vk/src/context.rs b/rust/crates/simv-vk/src/context.rs new file mode 100644 index 0000000..fa48cdd --- /dev/null +++ b/rust/crates/simv-vk/src/context.rs @@ -0,0 +1,570 @@ +//! Инстанс, устройство, очереди, аллокатор и кэш конвейеров — перенос +//! `simv::vk::Context`. +//! +//! Самый самостоятельный кусок порта. В C++ всё это строит vk-bootstrap: выбор +//! физического устройства по требуемым возможностям, поиск семейств очередей, +//! проверка наличия слоя валидации. Замены vk-bootstrap в экосистеме Rust нет, так что +//! ниже примерно двести пятьдесят строк, которых в C++-версии просто не существует. + +use std::ffi::{c_char, c_void, CStr}; +use std::sync::{Arc, Mutex}; + +use ash::vk; +use gpu_allocator::vulkan::{Allocator, AllocatorCreateDesc}; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; + +use crate::error::{Ctx, Result, VkError}; + +/// Пожелания к контексту. Значения по умолчанию совпадают с C++-версией, включая +/// включённую валидацию во всех конфигурациях сборки. +pub struct ContextOptions { + pub enable_validation: bool, + pub enable_debug_utils: bool, + pub require_async_compute: bool, + pub require_async_transfer: bool, +} + +impl Default for ContextOptions { + fn default() -> Self { + Self { + enable_validation: true, + enable_debug_utils: true, + require_async_compute: true, + require_async_transfer: true, + } + } +} + +const VALIDATION_LAYER: &CStr = c"VK_LAYER_KHRONOS_validation"; + +/// Vulkan 1.3: инстанс, поверхность, физическое и логическое устройство, очереди, +/// аллокатор памяти и кэш конвейеров. +/// +/// Владеет всем перечисленным и разрушает в обратном порядке в `Drop`. Всё, что +/// клонирует `ash::Device` (а это делают `GpuMesh`, `MeshRenderer`, `GridRenderer`), +/// обязано быть уничтожено раньше контекста: клон `Device` — это копия таблицы +/// функций, а не владеющая ссылка, и пережить `destroy_device` он не может. +pub struct Context { + device_name: String, + + graphics_queue: vk::Queue, + compute_queue: vk::Queue, + transfer_queue: vk::Queue, + graphics_family: u32, + compute_family: u32, + transfer_family: u32, + + pipeline_cache: vk::PipelineCache, + /// `Option` только ради `Drop`: аллокатор обязан умереть до устройства. + allocator: Option>>, + + swapchain_loader: ash::khr::swapchain::Device, + device: ash::Device, + physical_device: vk::PhysicalDevice, + + surface: vk::SurfaceKHR, + surface_loader: ash::khr::surface::Instance, + debug: Option<(ash::ext::debug_utils::Instance, vk::DebugUtilsMessengerEXT)>, + instance: ash::Instance, + /// `Option` ради `Drop`: при динамической загрузке `Entry` держит саму + /// библиотеку Vulkan, поэтому уйти она обязана последней — иначе + /// `destroy_instance` вызовет указатель в уже выгруженной DLL. + entry: Option, +} + +impl Context { + pub fn new(window: &simv_core::Window, opts: &ContextOptions) -> Result { + let entry = unsafe { ash::Entry::load() }?; + + let display_handle = window.raw().display_handle()?.as_raw(); + let window_handle = window.raw().window_handle()?.as_raw(); + + // ── Инстанс ────────────────────────────────────────────────────────── + let mut extensions: Vec<*const c_char> = + ash_window::enumerate_required_extensions(display_handle) + .ctx("ash_window::enumerate_required_extensions")? + .to_vec(); + if opts.enable_debug_utils { + extensions.push(ash::ext::debug_utils::NAME.as_ptr()); + } + + // Проверка наличия слоя обязательна: vk-bootstrap её делал, а без неё машина + // без установленного SDK не запустила бы приложение вовсе. + let mut layers: Vec<*const c_char> = Vec::new(); + if opts.enable_validation { + let available = unsafe { entry.enumerate_instance_layer_properties() } + .ctx("vkEnumerateInstanceLayerProperties")?; + let has_validation = available + .iter() + .any(|l| l.layer_name_as_c_str() == Ok(VALIDATION_LAYER)); + if has_validation { + layers.push(VALIDATION_LAYER.as_ptr()); + } else { + log::warn!(target: "vk", "слой валидации не установлен — иду без него"); + } + } + + let app_info = vk::ApplicationInfo::default() + .application_name(c"SimVulcan") + .engine_name(c"simv") + .api_version(vk::API_VERSION_1_3); + let instance_ci = vk::InstanceCreateInfo::default() + .application_info(&app_info) + .enabled_extension_names(&extensions) + .enabled_layer_names(&layers); + let instance = + unsafe { entry.create_instance(&instance_ci, None) }.ctx("vkCreateInstance")?; + + // Любой ранний выход дальше обязан прибрать уже созданное: `Drop` собирается + // только на полностью построенном контексте. Отсюда явный разбор ошибки. + match Self::build(&entry, &instance, display_handle, window_handle, opts) { + Ok(mut ctx) => { + ctx.entry = Some(entry); + Ok(ctx) + } + Err(e) => { + unsafe { instance.destroy_instance(None) }; + Err(e) + } + } + } + + /// Всё, что строится поверх готового инстанса. + fn build( + entry: &ash::Entry, + instance: &ash::Instance, + display_handle: raw_window_handle::RawDisplayHandle, + window_handle: raw_window_handle::RawWindowHandle, + opts: &ContextOptions, + ) -> Result { + // ── Сообщения валидации ────────────────────────────────────────────── + let debug = if opts.enable_debug_utils { + let loader = ash::ext::debug_utils::Instance::new(entry, instance); + let ci = vk::DebugUtilsMessengerCreateInfoEXT::default() + .message_severity( + vk::DebugUtilsMessageSeverityFlagsEXT::ERROR + | vk::DebugUtilsMessageSeverityFlagsEXT::WARNING, + ) + .message_type( + vk::DebugUtilsMessageTypeFlagsEXT::GENERAL + | vk::DebugUtilsMessageTypeFlagsEXT::VALIDATION + | vk::DebugUtilsMessageTypeFlagsEXT::PERFORMANCE, + ) + .pfn_user_callback(Some(debug_callback)); + let messenger = unsafe { loader.create_debug_utils_messenger(&ci, None) } + .ctx("vkCreateDebugUtilsMessengerEXT")?; + Some((loader, messenger)) + } else { + None + }; + + // ── Поверхность ────────────────────────────────────────────────────── + let surface_loader = ash::khr::surface::Instance::new(entry, instance); + let surface = unsafe { + ash_window::create_surface(entry, instance, display_handle, window_handle, None) + } + .ctx("vkCreate*SurfaceKHR")?; + + // ── Физическое устройство ──────────────────────────────────────────── + let chosen = select_physical_device(instance, &surface_loader, surface)?; + let device_name = chosen.name.clone(); + let physical_device = chosen.handle; + + // ── Логическое устройство ──────────────────────────────────────────── + let mut families = vec![chosen.graphics_family]; + for f in [chosen.compute_family, chosen.transfer_family] + .into_iter() + .flatten() + { + if !families.contains(&f) { + families.push(f); + } + } + let priority = [1.0f32]; + let queue_cis: Vec<_> = families + .iter() + .map(|&family| { + vk::DeviceQueueCreateInfo::default() + .queue_family_index(family) + .queue_priorities(&priority) + }) + .collect(); + + let device_extensions = [ash::khr::swapchain::NAME.as_ptr()]; + // Ровно тот набор, который включает C++-версия (Context.cpp: feat10/12/13). + let mut features13 = vk::PhysicalDeviceVulkan13Features::default() + .synchronization2(true) + .dynamic_rendering(true); + let mut features12 = vk::PhysicalDeviceVulkan12Features::default() + .timeline_semaphore(true) + .descriptor_indexing(true) + .buffer_device_address(true) + .shader_subgroup_extended_types(true) + .scalar_block_layout(true) + .runtime_descriptor_array(true); + // fillModeNonSolid — без него не собрать каркасный конвейер. + let features10 = vk::PhysicalDeviceFeatures::default().fill_mode_non_solid(true); + let mut features2 = vk::PhysicalDeviceFeatures2::default() + .features(features10) + .push_next(&mut features13) + .push_next(&mut features12); + + let device_ci = vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_cis) + .enabled_extension_names(&device_extensions) + .push_next(&mut features2); + + let device = unsafe { instance.create_device(physical_device, &device_ci, None) } + .ctx("vkCreateDevice")?; + + // ── Очереди ────────────────────────────────────────────────────────── + let graphics_family = chosen.graphics_family; + let graphics_queue = unsafe { device.get_device_queue(graphics_family, 0) }; + + let (compute_family, compute_queue) = match chosen.compute_family { + Some(f) if opts.require_async_compute => (f, unsafe { device.get_device_queue(f, 0) }), + _ => { + log::warn!(target: "vk", "выделенной вычислительной очереди нет — беру графическую"); + (graphics_family, graphics_queue) + } + }; + let (transfer_family, transfer_queue) = match chosen.transfer_family { + Some(f) if opts.require_async_transfer => (f, unsafe { device.get_device_queue(f, 0) }), + _ => { + log::warn!(target: "vk", "выделенной очереди передачи нет — беру графическую"); + (graphics_family, graphics_queue) + } + }; + + // ── Аллокатор ──────────────────────────────────────────────────────── + let allocator = Allocator::new(&AllocatorCreateDesc { + instance: instance.clone(), + device: device.clone(), + physical_device, + debug_settings: Default::default(), + buffer_device_address: true, + allocation_sizes: Default::default(), + }) + .map_err(|e| VkError::Other(format!("gpu-allocator: {e}")))?; + + let swapchain_loader = ash::khr::swapchain::Device::new(instance, &device); + let pipeline_cache = create_pipeline_cache(&device); + + log::info!(target: "vk", "контекст Vulkan готов: {device_name}"); + + Ok(Self { + device_name, + graphics_queue, + compute_queue, + transfer_queue, + graphics_family, + compute_family, + transfer_family, + pipeline_cache, + allocator: Some(Arc::new(Mutex::new(allocator))), + swapchain_loader, + device, + physical_device, + surface, + surface_loader, + debug, + instance: instance.clone(), + // Заполняет вызывающий: сюда `Entry` не передаётся, чтобы на пути ошибки + // инстанс уничтожался ровно один раз, а библиотека выгружалась после него. + entry: None, + }) + } + + pub fn device(&self) -> &ash::Device { + &self.device + } + pub fn instance(&self) -> &ash::Instance { + &self.instance + } + pub fn physical_device(&self) -> vk::PhysicalDevice { + self.physical_device + } + pub fn surface(&self) -> vk::SurfaceKHR { + self.surface + } + pub fn surface_loader(&self) -> &ash::khr::surface::Instance { + &self.surface_loader + } + pub fn swapchain_loader(&self) -> &ash::khr::swapchain::Device { + &self.swapchain_loader + } + pub fn allocator(&self) -> &Arc> { + self.allocator.as_ref().expect("аллокатор уже уничтожен") + } + pub fn graphics_queue(&self) -> vk::Queue { + self.graphics_queue + } + pub fn compute_queue(&self) -> vk::Queue { + self.compute_queue + } + pub fn transfer_queue(&self) -> vk::Queue { + self.transfer_queue + } + pub fn graphics_family(&self) -> u32 { + self.graphics_family + } + pub fn compute_family(&self) -> u32 { + self.compute_family + } + pub fn transfer_family(&self) -> u32 { + self.transfer_family + } + pub fn pipeline_cache(&self) -> vk::PipelineCache { + self.pipeline_cache + } + pub fn device_name(&self) -> &str { + &self.device_name + } + + pub fn wait_idle(&self) { + let _ = unsafe { self.device.device_wait_idle() }; + } +} + +impl Drop for Context { + fn drop(&mut self) { + unsafe { + let _ = self.device.device_wait_idle(); + + save_pipeline_cache(&self.device, self.pipeline_cache); + if self.pipeline_cache != vk::PipelineCache::null() { + self.device.destroy_pipeline_cache(self.pipeline_cache, None); + } + // Аллокатор обязан уйти до устройства, иначе он освободит память уже + // уничтоженного `VkDevice`. + drop(self.allocator.take()); + + self.device.destroy_device(None); + self.surface_loader.destroy_surface(self.surface, None); + if let Some((loader, messenger)) = self.debug.take() { + loader.destroy_debug_utils_messenger(messenger, None); + } + self.instance.destroy_instance(None); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Выбор физического устройства — то, что в C++ делает vkb::PhysicalDeviceSelector +// ───────────────────────────────────────────────────────────────────────────── + +struct ChosenDevice { + handle: vk::PhysicalDevice, + name: String, + graphics_family: u32, + /// Выделенное семейство: COMPUTE без GRAPHICS. `None` — такого нет. + compute_family: Option, + /// Выделенное семейство: TRANSFER без GRAPHICS и COMPUTE. + transfer_family: Option, + score: u32, +} + +fn select_physical_device( + instance: &ash::Instance, + surface_loader: &ash::khr::surface::Instance, + surface: vk::SurfaceKHR, +) -> Result { + let devices = + unsafe { instance.enumerate_physical_devices() }.ctx("vkEnumeratePhysicalDevices")?; + + let mut best: Option = None; + for handle in devices { + let Some(candidate) = inspect_device(instance, surface_loader, surface, handle) else { + continue; + }; + if best.as_ref().is_none_or(|b| candidate.score > b.score) { + best = Some(candidate); + } + } + best.ok_or(VkError::NoSuitableDevice) +} + +fn inspect_device( + instance: &ash::Instance, + surface_loader: &ash::khr::surface::Instance, + surface: vk::SurfaceKHR, + handle: vk::PhysicalDevice, +) -> Option { + let props = unsafe { instance.get_physical_device_properties(handle) }; + let name = props + .device_name_as_c_str() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|_| String::from("устройство без имени")); + + if props.api_version < vk::API_VERSION_1_3 { + log::debug!(target: "vk", "{name}: пропускаю, API ниже 1.3"); + return None; + } + + // Возможности: ровно тот набор, который требует C++-версия. + // + // Запрос обёрнут в блок не для красоты. `push_next` в ash берёт `&mut` на + // вложенную структуру и держит заимствование, пока жива голова цепочки, поэтому + // читать `f13`/`f12` при живом `f2` компилятор не даёт. В C++ pNext — сырой + // указатель, и там этот вопрос просто не возникает. Блок гасит заимствование: + // наружу выходят уже заполненные значения. + let (f13, f12, f10) = { + let mut f13 = vk::PhysicalDeviceVulkan13Features::default(); + let mut f12 = vk::PhysicalDeviceVulkan12Features::default(); + let mut f2 = vk::PhysicalDeviceFeatures2::default() + .push_next(&mut f13) + .push_next(&mut f12); + unsafe { instance.get_physical_device_features2(handle, &mut f2) }; + let f10 = f2.features; + (f13, f12, f10) + }; + + let missing = [ + ("synchronization2", f13.synchronization2 == vk::TRUE), + ("dynamicRendering", f13.dynamic_rendering == vk::TRUE), + ("timelineSemaphore", f12.timeline_semaphore == vk::TRUE), + ("descriptorIndexing", f12.descriptor_indexing == vk::TRUE), + ("bufferDeviceAddress", f12.buffer_device_address == vk::TRUE), + ( + "shaderSubgroupExtendedTypes", + f12.shader_subgroup_extended_types == vk::TRUE, + ), + ("scalarBlockLayout", f12.scalar_block_layout == vk::TRUE), + ( + "runtimeDescriptorArray", + f12.runtime_descriptor_array == vk::TRUE, + ), + ("fillModeNonSolid", f10.fill_mode_non_solid == vk::TRUE), + ] + .into_iter() + .filter_map(|(feature, ok)| (!ok).then_some(feature)) + .collect::>(); + if !missing.is_empty() { + log::debug!(target: "vk", "{name}: пропускаю, нет возможностей: {}", missing.join(", ")); + return None; + } + + let extensions = unsafe { instance.enumerate_device_extension_properties(handle) }.ok()?; + let has_swapchain = extensions + .iter() + .any(|e| e.extension_name_as_c_str() == Ok(ash::khr::swapchain::NAME)); + if !has_swapchain { + log::debug!(target: "vk", "{name}: пропускаю, нет VK_KHR_swapchain"); + return None; + } + + // Семейства очередей. Графическое обязано ещё и уметь показывать на поверхность: + // C++-версия полагалась на то, что vkb проверит это за неё. + let families = unsafe { instance.get_physical_device_queue_family_properties(handle) }; + let mut graphics_family = None; + let mut compute_family = None; + let mut transfer_family = None; + for (index, family) in families.iter().enumerate() { + let i = index as u32; + let flags = family.queue_flags; + let graphics = flags.contains(vk::QueueFlags::GRAPHICS); + let compute = flags.contains(vk::QueueFlags::COMPUTE); + let transfer = flags.contains(vk::QueueFlags::TRANSFER); + + if graphics_family.is_none() && graphics { + let presents = + unsafe { surface_loader.get_physical_device_surface_support(handle, i, surface) } + .unwrap_or(false); + if presents { + graphics_family = Some(i); + } + } + if compute_family.is_none() && compute && !graphics { + compute_family = Some(i); + } + if transfer_family.is_none() && transfer && !graphics && !compute { + transfer_family = Some(i); + } + } + let graphics_family = graphics_family?; + + let score = match props.device_type { + vk::PhysicalDeviceType::DISCRETE_GPU => 1000, + vk::PhysicalDeviceType::INTEGRATED_GPU => 100, + vk::PhysicalDeviceType::VIRTUAL_GPU => 10, + _ => 1, + }; + + Some(ChosenDevice { + handle, + name, + graphics_family, + compute_family, + transfer_family, + score, + }) +} + +// ───────────────────────────────────────────────────────────────────────────── +// Кэш конвейеров +// ───────────────────────────────────────────────────────────────────────────── + +/// Файл кэша лежит рядом с исполняемым файлом, а не в текущем каталоге, как в +/// C++-версии: запускать редактор из произвольного места теперь можно, и кэш при этом +/// не рассыпается по диску. +fn pipeline_cache_path() -> std::path::PathBuf { + std::env::current_exe() + .ok() + .and_then(|p| p.parent().map(|d| d.join("pipeline_cache.bin"))) + .unwrap_or_else(|| std::path::PathBuf::from("pipeline_cache.bin")) +} + +fn create_pipeline_cache(device: &ash::Device) -> vk::PipelineCache { + let blob = std::fs::read(pipeline_cache_path()).unwrap_or_default(); + let ci = vk::PipelineCacheCreateInfo::default().initial_data(&blob); + match unsafe { device.create_pipeline_cache(&ci, None) } { + Ok(cache) => { + log::info!(target: "vk", "кэш конвейеров загружен: {} байт", blob.len()); + cache + } + Err(e) => { + log::warn!( + target: "vk", + "кэш конвейеров не создан ({e:?}) — конвейеры будут пересобираться каждый запуск" + ); + vk::PipelineCache::null() + } + } +} + +fn save_pipeline_cache(device: &ash::Device, cache: vk::PipelineCache) { + if cache == vk::PipelineCache::null() { + return; + } + if let Ok(blob) = unsafe { device.get_pipeline_cache_data(cache) } { + if !blob.is_empty() { + let _ = std::fs::write(pipeline_cache_path(), &blob); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Сообщения слоя валидации +// ───────────────────────────────────────────────────────────────────────────── + +unsafe extern "system" fn debug_callback( + severity: vk::DebugUtilsMessageSeverityFlagsEXT, + _types: vk::DebugUtilsMessageTypeFlagsEXT, + data: *const vk::DebugUtilsMessengerCallbackDataEXT<'_>, + _user: *mut c_void, +) -> vk::Bool32 { + let message = unsafe { + data.as_ref() + .filter(|d| !d.p_message.is_null()) + .map(|d| CStr::from_ptr(d.p_message).to_string_lossy()) + .unwrap_or(std::borrow::Cow::Borrowed("сообщение без текста")) + }; + if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::ERROR) { + log::error!(target: "vk", "{message}"); + } else if severity.contains(vk::DebugUtilsMessageSeverityFlagsEXT::WARNING) { + log::warn!(target: "vk", "{message}"); + } else { + log::info!(target: "vk", "{message}"); + } + vk::FALSE +} diff --git a/rust/crates/simv-vk/src/error.rs b/rust/crates/simv-vk/src/error.rs new file mode 100644 index 0000000..df2f0d9 --- /dev/null +++ b/rust/crates/simv-vk/src/error.rs @@ -0,0 +1,43 @@ +//! Ошибки Vulkan-слоя. +//! +//! C++-версия бросала `std::runtime_error` со строкой, собранной на месте. Здесь +//! ошибка — значение: её нельзя не заметить, а `?` избавляет от той же ручной +//! проверки `if (r != VK_SUCCESS)`, которой в `Renderer.cpp` посвящена отдельная +//! функция `CheckVk`. + +use ash::vk; + +#[derive(Debug, thiserror::Error)] +pub enum VkError { + #[error("не удалось загрузить библиотеку Vulkan (нет драйвера или загрузчика): {0}")] + Loading(#[from] ash::LoadingError), + + #[error("{context} вернул ошибку: {source:?}")] + Api { + context: &'static str, + #[source] + source: vk::Result, + }, + + #[error("не найдено устройство Vulkan 1.3 с нужным набором возможностей")] + NoSuitableDevice, + + #[error("не удалось получить дескриптор окна: {0}")] + WindowHandle(#[from] raw_window_handle::HandleError), + + #[error("{0}")] + Other(String), +} + +pub type Result = std::result::Result; + +/// Навесить на результат ash имя вызова — то же, что делал `CheckVk(r, where)`. +pub(crate) trait Ctx { + fn ctx(self, context: &'static str) -> Result; +} + +impl Ctx for std::result::Result { + fn ctx(self, context: &'static str) -> Result { + self.map_err(|source| VkError::Api { context, source }) + } +} diff --git a/rust/crates/simv-vk/src/gpu_mesh.rs b/rust/crates/simv-vk/src/gpu_mesh.rs new file mode 100644 index 0000000..7b441f9 --- /dev/null +++ b/rust/crates/simv-vk/src/gpu_mesh.rs @@ -0,0 +1,158 @@ +//! Меш на видеокарте — перенос `simv::vk::GpuMesh`. +//! +//! Загрузка через промежуточный буфер на очереди передачи, с ожиданием забора. +//! Загрузки редки (открытие модели), так что блокирующая копия здесь уместна — но +//! копировать этот приём в покадровые пути нельзя. +//! +//! В C++ у `GpuMesh` есть состояние «пустой» и метод `IsValid()`, а у `Renderer` — +//! отдельный флаг `hasMesh`. Здесь оба исчезли: незагруженный меш выражается через +//! `Option`, и забыть проверить его невозможно — компилятор не даст. + +use ash::vk; +use simv_mesh::Mesh; + +use crate::buffer::{Buffer, MemoryLocation}; +use crate::context::Context; +use crate::error::{Ctx, Result, VkError}; + +pub struct GpuMesh { + vertex: Buffer, + index: Buffer, + vertex_count: u32, + index_count: u32, +} + +impl GpuMesh { + pub fn upload(ctx: &Context, mesh: &Mesh) -> Result { + if mesh.is_empty() { + return Err(VkError::Other(String::from( + "выгрузка пустого меша на видеокарту", + ))); + } + let vertex_count = mesh.positions.len() as u32; + let index_count = mesh.indices.len() as u32; + + let vertex_bytes: &[u8] = bytemuck::cast_slice(&mesh.positions); + let index_bytes: &[u8] = bytemuck::cast_slice(&mesh.indices); + + let vertex = Buffer::new( + ctx, + "mesh.vertices", + vertex_bytes.len() as vk::DeviceSize, + vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::VERTEX_BUFFER, + MemoryLocation::DeviceLocal, + )?; + let index = Buffer::new( + ctx, + "mesh.indices", + index_bytes.len() as vk::DeviceSize, + vk::BufferUsageFlags::TRANSFER_DST | vk::BufferUsageFlags::INDEX_BUFFER, + MemoryLocation::DeviceLocal, + )?; + + let mut vertex_stage = Buffer::new( + ctx, + "mesh.vertices.staging", + vertex_bytes.len() as vk::DeviceSize, + vk::BufferUsageFlags::TRANSFER_SRC, + MemoryLocation::HostVisible, + )?; + let mut index_stage = Buffer::new( + ctx, + "mesh.indices.staging", + index_bytes.len() as vk::DeviceSize, + vk::BufferUsageFlags::TRANSFER_SRC, + MemoryLocation::HostVisible, + )?; + vertex_stage.write(vertex_bytes, 0)?; + index_stage.write(index_bytes, 0)?; + + one_shot_copy( + ctx, + &[ + ( + vertex_stage.handle(), + vertex.handle(), + vertex_bytes.len() as vk::DeviceSize, + ), + ( + index_stage.handle(), + index.handle(), + index_bytes.len() as vk::DeviceSize, + ), + ], + )?; + + Ok(Self { + vertex, + index, + vertex_count, + index_count, + }) + } + + pub fn vertex_buffer(&self) -> vk::Buffer { + self.vertex.handle() + } + pub fn index_buffer(&self) -> vk::Buffer { + self.index.handle() + } + pub fn vertex_count(&self) -> u32 { + self.vertex_count + } + pub fn index_count(&self) -> u32 { + self.index_count + } +} + +/// Одноразовый командный буфер на очереди передачи: записать копии, отправить, +/// дождаться забора, прибрать за собой. +/// +/// C++-версия вызывала эту функцию по разу на буфер, то есть дважды отправляла работу +/// и дважды ждала. Здесь копии собраны в одну отправку — при загрузке `plane.obj` +/// это экономит одно полное ожидание забора. +fn one_shot_copy(ctx: &Context, copies: &[(vk::Buffer, vk::Buffer, vk::DeviceSize)]) -> Result<()> { + let device = ctx.device(); + + let pool_ci = vk::CommandPoolCreateInfo::default() + .flags(vk::CommandPoolCreateFlags::TRANSIENT) + .queue_family_index(ctx.transfer_family()); + let pool = unsafe { device.create_command_pool(&pool_ci, None) } + .ctx("vkCreateCommandPool(one-shot)")?; + + // Дальше любой выход обязан прибрать пул, поэтому тело вынесено в замыкание. + let result = (|| -> Result<()> { + let alloc_ci = vk::CommandBufferAllocateInfo::default() + .command_pool(pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + let cmd = unsafe { device.allocate_command_buffers(&alloc_ci) } + .ctx("vkAllocateCommandBuffers(one-shot)")?[0]; + + let begin = vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + unsafe { device.begin_command_buffer(cmd, &begin) }.ctx("vkBeginCommandBuffer")?; + for &(src, dst, size) in copies { + let region = [vk::BufferCopy::default().size(size)]; + unsafe { device.cmd_copy_buffer(cmd, src, dst, ®ion) }; + } + unsafe { device.end_command_buffer(cmd) }.ctx("vkEndCommandBuffer")?; + + let fence = unsafe { device.create_fence(&vk::FenceCreateInfo::default(), None) } + .ctx("vkCreateFence(one-shot)")?; + let cmds = [cmd]; + let submit = [vk::SubmitInfo::default().command_buffers(&cmds)]; + let submitted = unsafe { device.queue_submit(ctx.transfer_queue(), &submit, fence) }; + let waited = submitted + .ctx("vkQueueSubmit(one-shot)") + .and_then(|()| { + unsafe { device.wait_for_fences(&[fence], true, u64::MAX) } + .ctx("vkWaitForFences(one-shot)") + }); + unsafe { device.destroy_fence(fence, None) }; + waited + })(); + + unsafe { device.destroy_command_pool(pool, None) }; + result +} diff --git a/rust/crates/simv-vk/src/grid_renderer.rs b/rust/crates/simv-vk/src/grid_renderer.rs new file mode 100644 index 0000000..8ee2810 --- /dev/null +++ b/rust/crates/simv-vk/src/grid_renderer.rs @@ -0,0 +1,257 @@ +//! Опорная сетка и оси — перенос `simv::vk::GridRenderer`. +//! +//! Геометрия строится один раз в буфер, видимый процессору: она статична и крошечная, +//! так что промежуточная копия ради неё не нужна. Дескрипторных наборов нет — всё +//! состояние умещается в одну push-константу. + +use ash::vk; +use glam::{Mat4, Vec3}; + +use crate::buffer::{Buffer, MemoryLocation}; +use crate::context::Context; +use crate::error::{Ctx, Result}; +use crate::shader::{spirv, ShaderModule}; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct GridVertex { + pos: Vec3, + color: Vec3, +} + +/// Половина протяжённости сетки в клетках и размер клетки — как в C++-версии. +const HALF_EXTENT_CELLS: i32 = 10; +const CELL_SIZE: f32 = 1.0; + +pub struct GridRenderer { + device: ash::Device, + layout: vk::PipelineLayout, + pipeline: vk::Pipeline, + vertex_buffer: Buffer, + vertex_count: u32, + color_format: vk::Format, + depth_format: vk::Format, +} + +impl GridRenderer { + pub fn new(ctx: &Context) -> Result { + let device = ctx.device().clone(); + + let ranges = [vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::VERTEX) + .offset(0) + .size(std::mem::size_of::() as u32)]; + let layout_ci = vk::PipelineLayoutCreateInfo::default().push_constant_ranges(&ranges); + let layout = unsafe { device.create_pipeline_layout(&layout_ci, None) } + .ctx("vkCreatePipelineLayout(grid)")?; + + let vertices = build_geometry(); + let bytes: &[u8] = bytemuck::cast_slice(&vertices); + let mut vertex_buffer = Buffer::new( + ctx, + "grid.vertices", + bytes.len() as vk::DeviceSize, + vk::BufferUsageFlags::VERTEX_BUFFER, + MemoryLocation::HostVisible, + )?; + vertex_buffer.write(bytes, 0)?; + + Ok(Self { + device, + layout, + pipeline: vk::Pipeline::null(), + vertex_buffer, + vertex_count: vertices.len() as u32, + color_format: vk::Format::UNDEFINED, + depth_format: vk::Format::UNDEFINED, + }) + } + + /// Пересобрать конвейер, если форматы вложений сменились (пересоздание swapchain). + pub fn ensure_pipeline( + &mut self, + ctx: &Context, + color_format: vk::Format, + depth_format: vk::Format, + ) -> Result<()> { + if self.pipeline != vk::Pipeline::null() + && self.color_format == color_format + && self.depth_format == depth_format + { + return Ok(()); + } + self.destroy_pipeline(); + self.color_format = color_format; + self.depth_format = depth_format; + self.pipeline = self.build_pipeline(ctx)?; + Ok(()) + } + + fn build_pipeline(&self, ctx: &Context) -> Result { + // Модули живут до конца функции и уничтожаются на выходе, включая пути ошибок. + let vert = ShaderModule::new(&self.device, spirv::GRID_VERT)?; + let frag = ShaderModule::new(&self.device, spirv::GRID_FRAG)?; + let stages = [ + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::VERTEX) + .module(vert.handle()) + .name(c"main"), + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::FRAGMENT) + .module(frag.handle()) + .name(c"main"), + ]; + + let bindings = [vk::VertexInputBindingDescription::default() + .binding(0) + .stride(std::mem::size_of::() as u32) + .input_rate(vk::VertexInputRate::VERTEX)]; + let attributes = [ + vk::VertexInputAttributeDescription::default() + .location(0) + .binding(0) + .format(vk::Format::R32G32B32_SFLOAT) + .offset(std::mem::offset_of!(GridVertex, pos) as u32), + vk::VertexInputAttributeDescription::default() + .location(1) + .binding(0) + .format(vk::Format::R32G32B32_SFLOAT) + .offset(std::mem::offset_of!(GridVertex, color) as u32), + ]; + let vertex_input = vk::PipelineVertexInputStateCreateInfo::default() + .vertex_binding_descriptions(&bindings) + .vertex_attribute_descriptions(&attributes); + + let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default() + .topology(vk::PrimitiveTopology::LINE_LIST); + let viewport = vk::PipelineViewportStateCreateInfo::default() + .viewport_count(1) + .scissor_count(1); + let rasterization = vk::PipelineRasterizationStateCreateInfo::default() + // Для линейной топологии режим заливки не используется. + .polygon_mode(vk::PolygonMode::FILL) + .cull_mode(vk::CullModeFlags::NONE) + .line_width(1.0); + let multisample = vk::PipelineMultisampleStateCreateInfo::default() + .rasterization_samples(vk::SampleCountFlags::TYPE_1); + let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default() + .depth_test_enable(true) + .depth_write_enable(true) + .depth_compare_op(vk::CompareOp::LESS_OR_EQUAL) + .min_depth_bounds(0.0) + .max_depth_bounds(1.0); + let blend_attachments = [vk::PipelineColorBlendAttachmentState::default() + .color_write_mask(vk::ColorComponentFlags::RGBA)]; + let color_blend = + vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments); + let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR]; + let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states); + + let color_formats = [self.color_format]; + let mut rendering = vk::PipelineRenderingCreateInfo::default() + .color_attachment_formats(&color_formats) + .depth_attachment_format(self.depth_format); + + let create_info = [vk::GraphicsPipelineCreateInfo::default() + .stages(&stages) + .vertex_input_state(&vertex_input) + .input_assembly_state(&input_assembly) + .viewport_state(&viewport) + .rasterization_state(&rasterization) + .multisample_state(&multisample) + .depth_stencil_state(&depth_stencil) + .color_blend_state(&color_blend) + .dynamic_state(&dynamic) + .layout(self.layout) + .push_next(&mut rendering)]; + + let pipelines = unsafe { + self.device + .create_graphics_pipelines(ctx.pipeline_cache(), &create_info, None) + } + .map_err(|(_, e)| crate::error::VkError::Api { + context: "vkCreateGraphicsPipelines(grid)", + source: e, + })?; + Ok(pipelines[0]) + } + + pub fn draw(&self, cmd: vk::CommandBuffer, view_proj: &Mat4) { + if self.pipeline == vk::Pipeline::null() || self.vertex_count == 0 { + return; + } + unsafe { + self.device + .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, self.pipeline); + self.device.cmd_push_constants( + cmd, + self.layout, + vk::ShaderStageFlags::VERTEX, + 0, + bytemuck::bytes_of(view_proj), + ); + self.device + .cmd_bind_vertex_buffers(cmd, 0, &[self.vertex_buffer.handle()], &[0]); + self.device.cmd_draw(cmd, self.vertex_count, 1, 0, 0); + } + } + + fn destroy_pipeline(&mut self) { + if self.pipeline != vk::Pipeline::null() { + unsafe { self.device.destroy_pipeline(self.pipeline, None) }; + self.pipeline = vk::Pipeline::null(); + } + } +} + +impl Drop for GridRenderer { + fn drop(&mut self) { + self.destroy_pipeline(); + unsafe { self.device.destroy_pipeline_layout(self.layout, None) }; + } +} + +/// Три плоскости сетки через начало координат плюс цветные оси. +fn build_geometry() -> Vec { + const GRID: Vec3 = Vec3::new(0.30, 0.31, 0.35); + let n = HALF_EXTENT_CELLS; + let ext = n as f32 * CELL_SIZE; + + let mut verts = Vec::with_capacity(((2 * n + 1) * 6 * 2 + 6) as usize); + let mut line = |a: Vec3, b: Vec3, color: Vec3| { + verts.push(GridVertex { pos: a, color }); + verts.push(GridVertex { pos: b, color }); + }; + + for i in -n..=n { + let t = i as f32 * CELL_SIZE; + // Плоскость XZ (y = 0) + line(Vec3::new(-ext, 0.0, t), Vec3::new(ext, 0.0, t), GRID); + line(Vec3::new(t, 0.0, -ext), Vec3::new(t, 0.0, ext), GRID); + // Плоскость XY (z = 0) + line(Vec3::new(-ext, t, 0.0), Vec3::new(ext, t, 0.0), GRID); + line(Vec3::new(t, -ext, 0.0), Vec3::new(t, ext, 0.0), GRID); + // Плоскость YZ (x = 0) + line(Vec3::new(0.0, -ext, t), Vec3::new(0.0, ext, t), GRID); + line(Vec3::new(0.0, t, -ext), Vec3::new(0.0, t, ext), GRID); + } + + // Оси идут последними, чтобы перекрыть серые центральные линии. + line( + Vec3::new(-ext, 0.0, 0.0), + Vec3::new(ext, 0.0, 0.0), + Vec3::new(0.85, 0.25, 0.25), // X красная + ); + line( + Vec3::new(0.0, -ext, 0.0), + Vec3::new(0.0, ext, 0.0), + Vec3::new(0.25, 0.80, 0.30), // Y зелёная + ); + line( + Vec3::new(0.0, 0.0, -ext), + Vec3::new(0.0, 0.0, ext), + Vec3::new(0.30, 0.45, 0.90), // Z синяя + ); + + verts +} diff --git a/rust/crates/simv-vk/src/image.rs b/rust/crates/simv-vk/src/image.rs new file mode 100644 index 0000000..3f8f04f --- /dev/null +++ b/rust/crates/simv-vk/src/image.rs @@ -0,0 +1,182 @@ +//! Изображение с представлением — перенос `simv::vk::Image`. +//! +//! Как и `buffer.rs`, в C++-версии лежит без применения: глубинное вложение +//! `Renderer` создаёт там свои `VkImage`/`VkImageView` напрямую. Здесь на нём как раз +//! и построено глубинное вложение — см. заметку в `buffer.rs`. + +use std::sync::{Arc, Mutex}; + +use ash::vk; +use gpu_allocator::vulkan::{Allocation, AllocationCreateDesc, AllocationScheme, Allocator}; + +use crate::context::Context; +use crate::error::{Ctx, Result, VkError}; + +pub struct ImageDesc { + pub extent: vk::Extent3D, + pub format: vk::Format, + pub usage: vk::ImageUsageFlags, + pub image_type: vk::ImageType, + pub view_type: vk::ImageViewType, + pub aspect: vk::ImageAspectFlags, +} + +impl Default for ImageDesc { + fn default() -> Self { + Self { + extent: vk::Extent3D { + width: 1, + height: 1, + depth: 1, + }, + format: vk::Format::R8G8B8A8_UNORM, + usage: vk::ImageUsageFlags::SAMPLED, + image_type: vk::ImageType::TYPE_2D, + view_type: vk::ImageViewType::TYPE_2D, + aspect: vk::ImageAspectFlags::COLOR, + } + } +} + +pub struct Image { + device: ash::Device, + allocator: Arc>, + image: vk::Image, + view: vk::ImageView, + allocation: Option, + extent: vk::Extent3D, + format: vk::Format, + aspect: vk::ImageAspectFlags, + /// Текущая раскладка. Отслеживается здесь, чтобы `transition_to` не выставлял + /// барьер на переход в ту же раскладку — так же, как в C++-версии. + layout: vk::ImageLayout, +} + +impl Image { + pub fn new(ctx: &Context, name: &str, desc: &ImageDesc) -> Result { + let device = ctx.device().clone(); + let ci = vk::ImageCreateInfo::default() + .image_type(desc.image_type) + .format(desc.format) + .extent(desc.extent) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(desc.usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + let image = unsafe { device.create_image(&ci, None) }.ctx("vkCreateImage")?; + let requirements = unsafe { device.get_image_memory_requirements(image) }; + + let allocator = ctx.allocator().clone(); + let allocation = { + let mut guard = allocator.lock().expect("аллокатор отравлен паникой"); + guard.allocate(&AllocationCreateDesc { + name, + requirements, + location: gpu_allocator::MemoryLocation::GpuOnly, + linear: false, + allocation_scheme: AllocationScheme::GpuAllocatorManaged, + }) + }; + let allocation = match allocation { + Ok(a) => a, + Err(e) => { + unsafe { device.destroy_image(image, None) }; + return Err(VkError::Other(format!("gpu-allocator: {e}"))); + } + }; + unsafe { device.bind_image_memory(image, allocation.memory(), allocation.offset()) } + .ctx("vkBindImageMemory")?; + + let view_ci = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(desc.view_type) + .format(desc.format) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: desc.aspect, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }); + let view = unsafe { device.create_image_view(&view_ci, None) }.ctx("vkCreateImageView")?; + + Ok(Self { + device, + allocator, + image, + view, + allocation: Some(allocation), + extent: desc.extent, + format: desc.format, + aspect: desc.aspect, + layout: vk::ImageLayout::UNDEFINED, + }) + } + + pub fn image(&self) -> vk::Image { + self.image + } + pub fn view(&self) -> vk::ImageView { + self.view + } + pub fn extent(&self) -> vk::Extent3D { + self.extent + } + pub fn format(&self) -> vk::Format { + self.format + } + pub fn layout(&self) -> vk::ImageLayout { + self.layout + } + + /// Барьер перехода раскладки на sync2. Повторный переход в ту же раскладку — + /// не операция. + pub fn transition_to( + &mut self, + cmd: vk::CommandBuffer, + new_layout: vk::ImageLayout, + src_stage: vk::PipelineStageFlags2, + src_access: vk::AccessFlags2, + dst_stage: vk::PipelineStageFlags2, + dst_access: vk::AccessFlags2, + ) { + if new_layout == self.layout { + return; + } + let barrier = vk::ImageMemoryBarrier2::default() + .src_stage_mask(src_stage) + .src_access_mask(src_access) + .dst_stage_mask(dst_stage) + .dst_access_mask(dst_access) + .old_layout(self.layout) + .new_layout(new_layout) + .image(self.image) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: self.aspect, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }); + let barriers = [barrier]; + let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers); + unsafe { self.device.cmd_pipeline_barrier2(cmd, &dependency) }; + self.layout = new_layout; + } +} + +impl Drop for Image { + fn drop(&mut self) { + unsafe { self.device.destroy_image_view(self.view, None) }; + if let Some(allocation) = self.allocation.take() { + let _ = self + .allocator + .lock() + .map(|mut guard| guard.free(allocation)); + } + unsafe { self.device.destroy_image(self.image, None) }; + } +} diff --git a/rust/crates/simv-vk/src/lib.rs b/rust/crates/simv-vk/src/lib.rs new file mode 100644 index 0000000..6295ee7 --- /dev/null +++ b/rust/crates/simv-vk/src/lib.rs @@ -0,0 +1,26 @@ +//! Весь Vulkan редактора — соответствует цели `simv_vk`. +//! +//! Единственный крейт с `ash` в зависимостях. В C++ это правило («весь Vulkan живёт в +//! `src/vk/`») держится на дисциплине и комментарии в CLAUDE.md; здесь его проверяет +//! компоновщик зависимостей: у `simv-mesh` и `simv-editor` `ash` просто нет. +//! +//! Публичный интерфейс `Renderer` не содержит типов Vulkan — как и в C++-версии, где +//! ради этого заведён pImpl. В Rust того же добиваются приватные поля модуля, так что +//! `struct Renderer::Impl` и вся возня с `unique_ptr` не понадобились. + +pub mod buffer; +pub mod context; +pub mod error; +pub mod gpu_mesh; +pub mod grid_renderer; +pub mod image; +pub mod mesh_renderer; +pub mod render_mode; +pub mod renderer; +pub mod shader; +pub mod swapchain; + +pub use context::{Context, ContextOptions}; +pub use error::{Result, VkError}; +pub use render_mode::RenderMode; +pub use renderer::{FrameState, Renderer}; diff --git a/rust/crates/simv-vk/src/mesh_renderer.rs b/rust/crates/simv-vk/src/mesh_renderer.rs new file mode 100644 index 0000000..e62d568 --- /dev/null +++ b/rust/crates/simv-vk/src/mesh_renderer.rs @@ -0,0 +1,238 @@ +//! Конвейеры модели — перенос `simv::vk::MeshRenderer`. +//! +//! Два конвейера на один макет: заливка и `POLYGON_MODE_LINE` для каркаса. У линейного +//! включено небольшое смещение глубины, чтобы каркас лёг поверх заливки, а не спорил +//! с ней за z. Отсечение выключено — у загруженных моделей обход граней вперемешку. +//! +//! ВАЖНО: `MeshPushConstants` обязан байт в байт совпадать с блоком `push_constant` +//! в `shaders/editor/mesh.vert` и `mesh.frag`. Поле `color.a` — это ФЛАГ (1 — плоская +//! заливка, 0 — постоянный цвет каркаса), а не прозрачность. Меняете здесь — меняйте +//! и в шейдере. + +use ash::vk; +use glam::{Mat4, Vec4}; + +use crate::context::Context; +use crate::error::{Ctx, Result, VkError}; +use crate::gpu_mesh::GpuMesh; +use crate::render_mode::RenderMode; +use crate::shader::{spirv, ShaderModule}; + +#[repr(C)] +#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)] +struct MeshPushConstants { + mvp: Mat4, + color: Vec4, +} + +/// `color.a`: 1 — плоская заливка, 0 — постоянный цвет. +const SOLID: Vec4 = Vec4::new(0.72, 0.74, 0.78, 1.0); +const WIRE: Vec4 = Vec4::new(0.78, 0.82, 0.88, 0.0); +const OVERLAY: Vec4 = Vec4::new(0.06, 0.07, 0.09, 0.0); + +pub struct MeshRenderer { + device: ash::Device, + layout: vk::PipelineLayout, + fill: vk::Pipeline, + line: vk::Pipeline, + color_format: vk::Format, + depth_format: vk::Format, +} + +impl MeshRenderer { + pub fn new(ctx: &Context) -> Result { + let device = ctx.device().clone(); + let ranges = [vk::PushConstantRange::default() + .stage_flags(vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT) + .offset(0) + .size(std::mem::size_of::() as u32)]; + let layout_ci = vk::PipelineLayoutCreateInfo::default().push_constant_ranges(&ranges); + let layout = unsafe { device.create_pipeline_layout(&layout_ci, None) } + .ctx("vkCreatePipelineLayout(mesh)")?; + + Ok(Self { + device, + layout, + fill: vk::Pipeline::null(), + line: vk::Pipeline::null(), + color_format: vk::Format::UNDEFINED, + depth_format: vk::Format::UNDEFINED, + }) + } + + pub fn ensure_pipelines( + &mut self, + ctx: &Context, + color_format: vk::Format, + depth_format: vk::Format, + ) -> Result<()> { + if self.fill != vk::Pipeline::null() + && self.color_format == color_format + && self.depth_format == depth_format + { + return Ok(()); + } + self.destroy_pipelines(); + self.color_format = color_format; + self.depth_format = depth_format; + self.fill = self.build_pipeline(ctx, vk::PolygonMode::FILL, false)?; + self.line = self.build_pipeline(ctx, vk::PolygonMode::LINE, true)?; + Ok(()) + } + + fn build_pipeline( + &self, + ctx: &Context, + polygon_mode: vk::PolygonMode, + depth_bias: bool, + ) -> Result { + let vert = ShaderModule::new(&self.device, spirv::MESH_VERT)?; + let frag = ShaderModule::new(&self.device, spirv::MESH_FRAG)?; + let stages = [ + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::VERTEX) + .module(vert.handle()) + .name(c"main"), + vk::PipelineShaderStageCreateInfo::default() + .stage(vk::ShaderStageFlags::FRAGMENT) + .module(frag.handle()) + .name(c"main"), + ]; + + // Поток вершин — плотный float3: `mesh.frag` восстанавливает нормаль грани из + // экранных производных, так что нормали передавать не нужно. + let bindings = [vk::VertexInputBindingDescription::default() + .binding(0) + .stride(std::mem::size_of::() as u32 * 3) + .input_rate(vk::VertexInputRate::VERTEX)]; + let attributes = [vk::VertexInputAttributeDescription::default() + .location(0) + .binding(0) + .format(vk::Format::R32G32B32_SFLOAT) + .offset(0)]; + let vertex_input = vk::PipelineVertexInputStateCreateInfo::default() + .vertex_binding_descriptions(&bindings) + .vertex_attribute_descriptions(&attributes); + + let input_assembly = vk::PipelineInputAssemblyStateCreateInfo::default() + .topology(vk::PrimitiveTopology::TRIANGLE_LIST); + let viewport = vk::PipelineViewportStateCreateInfo::default() + .viewport_count(1) + .scissor_count(1); + + let mut rasterization = vk::PipelineRasterizationStateCreateInfo::default() + .polygon_mode(polygon_mode) + // У загруженных моделей обход граней бывает вперемешку. + .cull_mode(vk::CullModeFlags::NONE) + .front_face(vk::FrontFace::COUNTER_CLOCKWISE) + .line_width(1.0); + if depth_bias { + // Подвинуть каркас к камере, чтобы он лёг поверх заливки. + rasterization = rasterization + .depth_bias_enable(true) + .depth_bias_constant_factor(-1.0) + .depth_bias_slope_factor(-1.0); + } + + let multisample = vk::PipelineMultisampleStateCreateInfo::default() + .rasterization_samples(vk::SampleCountFlags::TYPE_1); + let depth_stencil = vk::PipelineDepthStencilStateCreateInfo::default() + .depth_test_enable(true) + .depth_write_enable(true) + .depth_compare_op(vk::CompareOp::LESS_OR_EQUAL) + .min_depth_bounds(0.0) + .max_depth_bounds(1.0); + let blend_attachments = [vk::PipelineColorBlendAttachmentState::default() + .color_write_mask(vk::ColorComponentFlags::RGBA)]; + let color_blend = + vk::PipelineColorBlendStateCreateInfo::default().attachments(&blend_attachments); + let dynamic_states = [vk::DynamicState::VIEWPORT, vk::DynamicState::SCISSOR]; + let dynamic = vk::PipelineDynamicStateCreateInfo::default().dynamic_states(&dynamic_states); + + let color_formats = [self.color_format]; + let mut rendering = vk::PipelineRenderingCreateInfo::default() + .color_attachment_formats(&color_formats) + .depth_attachment_format(self.depth_format); + + let create_info = [vk::GraphicsPipelineCreateInfo::default() + .stages(&stages) + .vertex_input_state(&vertex_input) + .input_assembly_state(&input_assembly) + .viewport_state(&viewport) + .rasterization_state(&rasterization) + .multisample_state(&multisample) + .depth_stencil_state(&depth_stencil) + .color_blend_state(&color_blend) + .dynamic_state(&dynamic) + .layout(self.layout) + .push_next(&mut rendering)]; + + let pipelines = unsafe { + self.device + .create_graphics_pipelines(ctx.pipeline_cache(), &create_info, None) + } + .map_err(|(_, e)| VkError::Api { + context: "vkCreateGraphicsPipelines(mesh)", + source: e, + })?; + Ok(pipelines[0]) + } + + pub fn draw(&self, cmd: vk::CommandBuffer, mesh: &GpuMesh, mvp: &Mat4, mode: RenderMode) { + if self.fill == vk::Pipeline::null() || self.line == vk::Pipeline::null() { + return; + } + unsafe { + self.device + .cmd_bind_vertex_buffers(cmd, 0, &[mesh.vertex_buffer()], &[0]); + self.device + .cmd_bind_index_buffer(cmd, mesh.index_buffer(), 0, vk::IndexType::UINT32); + } + + let draw_with = |pipeline: vk::Pipeline, color: Vec4| { + let push = MeshPushConstants { mvp: *mvp, color }; + unsafe { + self.device + .cmd_bind_pipeline(cmd, vk::PipelineBindPoint::GRAPHICS, pipeline); + self.device.cmd_push_constants( + cmd, + self.layout, + vk::ShaderStageFlags::VERTEX | vk::ShaderStageFlags::FRAGMENT, + 0, + bytemuck::bytes_of(&push), + ); + self.device + .cmd_draw_indexed(cmd, mesh.index_count(), 1, 0, 0, 0); + } + }; + + match mode { + RenderMode::Solid => draw_with(self.fill, SOLID), + RenderMode::Wireframe => draw_with(self.line, WIRE), + RenderMode::SolidWire => { + draw_with(self.fill, SOLID); + draw_with(self.line, OVERLAY); + } + } + } + + fn destroy_pipelines(&mut self) { + unsafe { + if self.fill != vk::Pipeline::null() { + self.device.destroy_pipeline(self.fill, None); + self.fill = vk::Pipeline::null(); + } + if self.line != vk::Pipeline::null() { + self.device.destroy_pipeline(self.line, None); + self.line = vk::Pipeline::null(); + } + } + } +} + +impl Drop for MeshRenderer { + fn drop(&mut self) { + self.destroy_pipelines(); + unsafe { self.device.destroy_pipeline_layout(self.layout, None) }; + } +} diff --git a/rust/crates/simv-vk/src/render_mode.rs b/rust/crates/simv-vk/src/render_mode.rs new file mode 100644 index 0000000..f95d30a --- /dev/null +++ b/rust/crates/simv-vk/src/render_mode.rs @@ -0,0 +1,13 @@ +//! Режим отображения модели — перенос `simv::vk::RenderMode`. + +/// Как рисуется загруженная модель во вьюпорте. +#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)] +pub enum RenderMode { + /// Залитые треугольники с плоской заливкой по нормали грани. + #[default] + Solid, + /// Только рёбра треугольников. + Wireframe, + /// Заливка с наложенным поверх каркасом. + SolidWire, +} diff --git a/rust/crates/simv-vk/src/renderer.rs b/rust/crates/simv-vk/src/renderer.rs new file mode 100644 index 0000000..3a5fdab --- /dev/null +++ b/rust/crates/simv-vk/src/renderer.rs @@ -0,0 +1,626 @@ +//! Кадр целиком — перенос `simv::vk::Renderer`. +//! +//! Владеет всем стеком показа: контекстом, цепочкой показа, глубинным вложением, +//! покадровыми командными буферами и синхронизацией, бэкендом egui и обоими +//! рендерерами сцены. В публичном интерфейсе нет ни одного типа Vulkan — ровно та же +//! цель, ради которой в C++ заведён pImpl, но здесь её решают приватные поля модуля, +//! так что `struct Renderer::Impl` и `unique_ptr` не понадобились. +//! +//! Расхождение с C++-версией: там замыкание интерфейса хранится полем +//! (`SetUiCallback`), здесь оно передаётся параметром в [`Renderer::draw_frame`]. +//! Причина не стилистическая. Замыкание в C++ захватывает по ссылке разом камеру, +//! панели и сам рендерер; в Rust хранить такой `FnMut` внутри объекта, который это же +//! замыкание и вызывает, значит одолжить рендерер дважды — компилятор не пропустит. +//! Параметр развязывает время жизни и ничего не стоит. + +use ash::vk; +use egui_ash_renderer::{DynamicRendering, Options}; +use glam::Mat4; +use simv_mesh::Mesh; + +use crate::context::{Context, ContextOptions}; +use crate::error::{Ctx, Result, VkError}; +use crate::gpu_mesh::GpuMesh; +use crate::grid_renderer::GridRenderer; +use crate::image::{Image, ImageDesc}; +use crate::mesh_renderer::MeshRenderer; +use crate::render_mode::RenderMode; +use crate::swapchain::Swapchain; + +/// Бэкенд egui поверх нашего же аллокатора: обёртка `GpuAllocator` — это +/// `Arc>`, тот самый, что раздаёт память +/// буферам и изображениям сцены. Одна куча на всё приложение. +type EguiRenderer = egui_ash_renderer::Renderer; + +const FRAMES_IN_FLIGHT: usize = 2; +const DEPTH_FORMAT: vk::Format = vk::Format::D32_SFLOAT; +const CLEAR_COLOR: [f32; 4] = [0.05, 0.06, 0.08, 1.0]; + +struct FrameSlot { + cmd_pool: vk::CommandPool, + cmd: vk::CommandBuffer, + image_available: vk::Semaphore, + in_flight: vk::Fence, + /// Текстуры egui, которые перестали быть нужны на кадре, использовавшем этот слот. + /// Освобождаются, когда забор слота снова сработает, — то есть когда видеокарта + /// точно закончила с тем кадром. + textures_to_free: Vec, +} + +pub struct Renderer { + // Поля объявлены в порядке уничтожения: всё, что держит клон `ash::Device` или + // ссылку на аллокатор, обязано уйти раньше контекста, а контекст — последним. + egui_renderer: EguiRenderer, + egui_state: egui_winit::State, + egui_ctx: egui::Context, + egui_pool: vk::CommandPool, + + gpu_mesh: Option, + mesh_renderer: MeshRenderer, + grid_renderer: GridRenderer, + + depth: Image, + frames: Vec, + render_finished: Vec, + cursor: usize, + + swapchain: Swapchain, + context: Context, + + /// Матрица модели: загруженная сетка переносится в начало координат. + model: Mat4, +} + +/// Что интерфейс сообщает сцене на этом кадре. +/// +/// В C++ то же состояние попадало в рендерер сеттерами (`SetViewProj`, +/// `SetRenderMode`, `SetGridVisible`), которые UI-колбэк вызывал прямо из +/// `DrawFrame`. Здесь колбэк вызывается из метода рендерера, поэтому трогать +/// рендерер оттуда нельзя — он уже одолжен. Состояние вместо этого возвращается +/// из замыкания и применяется к тому же кадру, без отставания на кадр. +pub struct FrameState { + pub view_proj: Mat4, + pub render_mode: RenderMode, + pub grid_visible: bool, +} + +impl Renderer { + pub fn new(window: &simv_core::Window) -> Result { + let context = Context::new( + window, + &ContextOptions { + // В редакторе нет вычислительной работы. + require_async_compute: false, + ..Default::default() + }, + )?; + let swapchain = Swapchain::new(&context, window, true)?; + + let (frames, render_finished) = create_frame_state(&context, swapchain.frames().len())?; + let depth = create_depth(&context, swapchain.extent())?; + + let egui_ctx = egui::Context::default(); + let egui_state = egui_winit::State::new( + egui_ctx.clone(), + egui::ViewportId::ROOT, + window.raw(), + Some(window.raw().scale_factor() as f32), + None, + None, + ); + let egui_pool = { + let ci = vk::CommandPoolCreateInfo::default() + .flags(vk::CommandPoolCreateFlags::TRANSIENT) + .queue_family_index(context.graphics_family()); + unsafe { context.device().create_command_pool(&ci, None) } + .ctx("vkCreateCommandPool(egui)")? + }; + let egui_renderer = EguiRenderer::with_gpu_allocator( + context.allocator().clone(), + context.device().clone(), + egui_ash_renderer::RenderMode::DynamicRendering(dynamic_rendering(&swapchain)), + egui_options(), + ) + .map_err(|e| VkError::Other(format!("egui_ash_renderer: {e}")))?; + + let mut mesh_renderer = MeshRenderer::new(&context)?; + let mut grid_renderer = GridRenderer::new(&context)?; + mesh_renderer.ensure_pipelines(&context, swapchain.color_format(), DEPTH_FORMAT)?; + grid_renderer.ensure_pipeline(&context, swapchain.color_format(), DEPTH_FORMAT)?; + + Ok(Self { + egui_renderer, + egui_state, + egui_ctx, + egui_pool, + gpu_mesh: None, + mesh_renderer, + grid_renderer, + depth, + frames, + render_finished, + cursor: 0, + swapchain, + context, + model: Mat4::IDENTITY, + }) + } + + /// Отношение сторон кадрового буфера — для матрицы проекции. + pub fn aspect_ratio(&self) -> f32 { + let extent = self.swapchain.extent(); + if extent.height == 0 { + return 1.0; + } + extent.width as f32 / extent.height as f32 + } + + /// Скормить событие окна интерфейсу. Возвращает `true`, если egui его забрал себе + /// и сцене оно не предназначено. + pub fn on_window_event( + &mut self, + window: &simv_core::Window, + event: &winit::event::WindowEvent, + ) -> bool { + self.egui_state + .on_window_event(window.raw(), event) + .consumed + } + + /// Выгрузить меш на видеокарту и заново отцентрировать модель на начале координат. + /// + /// Ожидание простоя устройства здесь допустимо: загрузки редки. В покадровые пути + /// этот приём переносить нельзя. + pub fn set_mesh_cpu(&mut self, mesh: &Mesh) -> Result<()> { + if mesh.is_empty() { + return Ok(()); + } + self.context.wait_idle(); + self.gpu_mesh = Some(GpuMesh::upload(&self.context, mesh)?); + let center = 0.5 * (mesh.bbox_min + mesh.bbox_max); + self.model = Mat4::from_translation(-center); + Ok(()) + } + + pub fn clear_mesh(&mut self) { + self.context.wait_idle(); + self.gpu_mesh = None; + self.model = Mat4::IDENTITY; + } + + pub fn wait_idle(&self) { + self.context.wait_idle(); + } + + /// Нарисовать кадр: интерфейс, сетка, модель — и показать. + /// + /// Замыкание вызывается между сбором ввода и тесселяцией, то есть ровно там же, + /// где C++-версия зовёт `uiCallback` между `ImGui::NewFrame` и `ImGui::Render`. + pub fn draw_frame( + &mut self, + window: &simv_core::Window, + mut ui: impl FnMut(&egui::Context) -> FrameState, + ) -> Result<()> { + let (fbw, fbh) = window.framebuffer_size(); + if fbw == 0 || fbh == 0 { + return Ok(()); // свёрнуто — рисовать некуда + } + + let slot = self.cursor % FRAMES_IN_FLIGHT; + let device = self.context.device().clone(); + + let in_flight = self.frames[slot].in_flight; + unsafe { device.wait_for_fences(&[in_flight], true, u64::MAX) }.ctx("vkWaitForFences")?; + + // Забор сработал — значит кадр, занимавший этот слот, отсчитан, и его текстуры + // можно отдавать. + for id in std::mem::take(&mut self.frames[slot].textures_to_free) { + self.egui_renderer + .free_texture(id) + .map_err(|e| VkError::Other(format!("egui free_texture: {e}")))?; + } + + let acquired = unsafe { + self.context.swapchain_loader().acquire_next_image( + self.swapchain.handle(), + u64::MAX, + self.frames[slot].image_available, + vk::Fence::null(), + ) + }; + let image_index = match acquired { + Ok((index, _suboptimal)) => index as usize, + Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swap_dependent(window)?; + return Ok(()); + } + Err(e) => return Err(VkError::Api { context: "vkAcquireNextImageKHR", source: e }), + }; + + // ── Кадр egui: панели заполняются переданным замыканием ────────────── + let raw_input = self.egui_state.take_egui_input(window.raw()); + // egui может прогнать интерфейс не один раз за кадр (например, если виджет + // попросил пересчёт), поэтому берётся последнее состояние. + let mut frame_state = None; + let mut output = self.egui_ctx.run_ui(raw_input, |u| { + let ctx = u.ctx().clone(); + frame_state = Some(ui(&ctx)); + }); + let frame_state = frame_state.expect("замыкание интерфейса не вернуло состояние кадра"); + self.egui_state + .handle_platform_output(window.raw(), output.platform_output); + let primitives = self + .egui_ctx + .tessellate(output.shapes, output.pixels_per_point); + + // egui 0.36 отдаёт на текстуру список правок, а не одну: атлас шрифта может + // дорасти несколькими кусками за кадр. + for (&id, deltas) in &output.textures_delta.set { + for delta in deltas { + self.egui_renderer + .set_texture( + self.context.graphics_queue(), + self.egui_pool, + id, + delta, + ) + .map_err(|e| VkError::Other(format!("egui set_texture: {e}")))?; + } + } + // Освобождение откладывается на два кадра: сейчас эти текстуры ещё попадут в + // командный буфер, который только предстоит отправить. + self.frames[slot] + .textures_to_free + .extend(output.textures_delta.free.iter().copied()); + // У `TexturesDelta` есть `Drop`, который в отладочной сборке падает, если + // правки уронили не применив. Разобрали — говорим об этом явно. + output.textures_delta.clear(); + + // ── Запись команд ──────────────────────────────────────────────────── + let cmd = self.frames[slot].cmd; + unsafe { + device.reset_fences(&[in_flight]).ctx("vkResetFences")?; + device + .reset_command_buffer(cmd, vk::CommandBufferResetFlags::empty()) + .ctx("vkResetCommandBuffer")?; + let begin = vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + device + .begin_command_buffer(cmd, &begin) + .ctx("vkBeginCommandBuffer")?; + } + + let swap_image = self.swapchain.frames()[image_index].image; + let swap_view = self.swapchain.frames()[image_index].view; + let extent = self.swapchain.extent(); + + // Цвет: UNDEFINED → COLOR_ATTACHMENT_OPTIMAL, глубина: UNDEFINED → DEPTH_ATTACHMENT. + { + let color_barrier = vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::TOP_OF_PIPE) + .dst_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT) + .dst_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .image(swap_image) + .subresource_range(color_range()); + let depth_barrier = vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::TOP_OF_PIPE) + .dst_stage_mask( + vk::PipelineStageFlags2::EARLY_FRAGMENT_TESTS + | vk::PipelineStageFlags2::LATE_FRAGMENT_TESTS, + ) + .dst_access_mask(vk::AccessFlags2::DEPTH_STENCIL_ATTACHMENT_WRITE) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL) + .image(self.depth.image()) + .subresource_range(depth_range()); + let barriers = [color_barrier, depth_barrier]; + let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers); + unsafe { device.cmd_pipeline_barrier2(cmd, &dependency) }; + } + + let color_attachment = vk::RenderingAttachmentInfo::default() + .image_view(swap_view) + .image_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .load_op(vk::AttachmentLoadOp::CLEAR) + .store_op(vk::AttachmentStoreOp::STORE) + .clear_value(vk::ClearValue { + color: vk::ClearColorValue { + float32: CLEAR_COLOR, + }, + }); + let depth_attachment = vk::RenderingAttachmentInfo::default() + .image_view(self.depth.view()) + .image_layout(vk::ImageLayout::DEPTH_ATTACHMENT_OPTIMAL) + .load_op(vk::AttachmentLoadOp::CLEAR) + .store_op(vk::AttachmentStoreOp::DONT_CARE) + .clear_value(vk::ClearValue { + depth_stencil: vk::ClearDepthStencilValue { + depth: 1.0, + stencil: 0, + }, + }); + let color_attachments = [color_attachment]; + let rendering = vk::RenderingInfo::default() + .render_area(vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }) + .layer_count(1) + .color_attachments(&color_attachments) + .depth_attachment(&depth_attachment); + + unsafe { + device.cmd_begin_rendering(cmd, &rendering); + + // Область вывода и отсечения общие для обоих рендереров сцены. + let viewport = [vk::Viewport { + x: 0.0, + y: 0.0, + width: extent.width as f32, + height: extent.height as f32, + min_depth: 0.0, + max_depth: 1.0, + }]; + let scissor = [vk::Rect2D { + offset: vk::Offset2D { x: 0, y: 0 }, + extent, + }]; + device.cmd_set_viewport(cmd, 0, &viewport); + device.cmd_set_scissor(cmd, 0, &scissor); + } + + if frame_state.grid_visible { + self.grid_renderer.draw(cmd, &frame_state.view_proj); + } + if let Some(mesh) = &self.gpu_mesh { + self.mesh_renderer.draw( + cmd, + mesh, + &(frame_state.view_proj * self.model), + frame_state.render_mode, + ); + } + + self.egui_renderer + .cmd_draw(cmd, extent, output.pixels_per_point, &primitives) + .map_err(|e| VkError::Other(format!("egui cmd_draw: {e}")))?; + + unsafe { + device.cmd_end_rendering(cmd); + + // Цвет: COLOR_ATTACHMENT_OPTIMAL → PRESENT_SRC. + let present_barrier = [vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT) + .src_access_mask(vk::AccessFlags2::COLOR_ATTACHMENT_WRITE) + .dst_stage_mask(vk::PipelineStageFlags2::BOTTOM_OF_PIPE) + .old_layout(vk::ImageLayout::COLOR_ATTACHMENT_OPTIMAL) + .new_layout(vk::ImageLayout::PRESENT_SRC_KHR) + .image(swap_image) + .subresource_range(color_range())]; + let dependency = vk::DependencyInfo::default().image_memory_barriers(&present_barrier); + device.cmd_pipeline_barrier2(cmd, &dependency); + device + .end_command_buffer(cmd) + .ctx("vkEndCommandBuffer")?; + } + + // ── Отправка и показ ───────────────────────────────────────────────── + let render_finished = self.render_finished[image_index]; + let wait = [vk::SemaphoreSubmitInfo::default() + .semaphore(self.frames[slot].image_available) + .stage_mask(vk::PipelineStageFlags2::COLOR_ATTACHMENT_OUTPUT)]; + let signal = [vk::SemaphoreSubmitInfo::default() + .semaphore(render_finished) + .stage_mask(vk::PipelineStageFlags2::ALL_GRAPHICS)]; + let cmd_infos = [vk::CommandBufferSubmitInfo::default().command_buffer(cmd)]; + let submit = [vk::SubmitInfo2::default() + .wait_semaphore_infos(&wait) + .command_buffer_infos(&cmd_infos) + .signal_semaphore_infos(&signal)]; + unsafe { device.queue_submit2(self.context.graphics_queue(), &submit, in_flight) } + .ctx("vkQueueSubmit2")?; + + let swapchains = [self.swapchain.handle()]; + let wait_semaphores = [render_finished]; + let indices = [image_index as u32]; + let present = vk::PresentInfoKHR::default() + .wait_semaphores(&wait_semaphores) + .swapchains(&swapchains) + .image_indices(&indices); + let presented = unsafe { + self.context + .swapchain_loader() + .queue_present(self.context.graphics_queue(), &present) + }; + match presented { + Ok(false) => {} + Ok(true) | Err(vk::Result::ERROR_OUT_OF_DATE_KHR) => { + self.recreate_swap_dependent(window)?; + } + Err(e) => { + return Err(VkError::Api { + context: "vkQueuePresentKHR", + source: e, + }) + } + } + + self.cursor = self.cursor.wrapping_add(1); + Ok(()) + } + + /// Пересобрать всё, что зависит от цепочки показа. + /// + /// Порядок важен и перенесён из C++-версии дословно: неудачный `vkAcquireNextImage` + /// может оставить семафор `image_available` взведённым, поэтому покадровые семафоры + /// пересоздаются целиком; число образов цепочки могло измениться, поэтому семафоры + /// «кадр готов» тоже; и только потом переcобираются конвейеры. + fn recreate_swap_dependent(&mut self, window: &simv_core::Window) -> Result<()> { + self.context.wait_idle(); + self.swapchain.recreate(&self.context, window, true)?; + self.depth = create_depth(&self.context, self.swapchain.extent())?; + + let device = self.context.device(); + for frame in &mut self.frames { + unsafe { device.destroy_semaphore(frame.image_available, None) }; + frame.image_available = + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) } + .ctx("vkCreateSemaphore")?; + } + for semaphore in self.render_finished.drain(..) { + unsafe { device.destroy_semaphore(semaphore, None) }; + } + for _ in 0..self.swapchain.frames().len() { + self.render_finished.push( + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) } + .ctx("vkCreateSemaphore")?, + ); + } + + let color_format = self.swapchain.color_format(); + self.mesh_renderer + .ensure_pipelines(&self.context, color_format, DEPTH_FORMAT)?; + self.grid_renderer + .ensure_pipeline(&self.context, color_format, DEPTH_FORMAT)?; + self.egui_renderer + .set_render_mode(egui_ash_renderer::RenderMode::DynamicRendering( + dynamic_rendering(&self.swapchain), + )) + .map_err(|e| VkError::Other(format!("egui set_render_mode: {e}")))?; + Ok(()) + } +} + +impl Drop for Renderer { + fn drop(&mut self) { + self.context.wait_idle(); + let device = self.context.device(); + unsafe { + for frame in &self.frames { + device.destroy_fence(frame.in_flight, None); + device.destroy_semaphore(frame.image_available, None); + device.destroy_command_pool(frame.cmd_pool, None); + } + for &semaphore in &self.render_finished { + device.destroy_semaphore(semaphore, None); + } + device.destroy_command_pool(self.egui_pool, None); + } + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// Вспомогательное +// ───────────────────────────────────────────────────────────────────────────── + +fn color_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + } +} + +fn depth_range() -> vk::ImageSubresourceRange { + vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::DEPTH, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + } +} + +/// Форматы вложений для конвейера egui. Глубина здесь не для того, чтобы egui её +/// использовал, а чтобы его конвейер совпал с проходом: интерфейс рисуется в тот же +/// `vkCmdBeginRendering`, что и сцена, а у прохода есть глубинное вложение. +fn dynamic_rendering(swapchain: &Swapchain) -> DynamicRendering { + DynamicRendering { + color_attachment_format: swapchain.color_format(), + depth_attachment_format: Some(DEPTH_FORMAT), + stencil_attachment_format: None, + } +} + +fn egui_options() -> Options { + Options { + in_flight_frames: FRAMES_IN_FLIGHT, + // Интерфейс рисуется поверх сцены и в глубину не пишет. + enable_depth_test: false, + enable_depth_write: false, + // Цель показа в формате UNORM, значит перевод в sRGB делает фрагментный шейдер. + srgb_framebuffer: false, + } +} + +fn create_depth(ctx: &Context, extent: vk::Extent2D) -> Result { + Image::new( + ctx, + "depth", + &ImageDesc { + extent: vk::Extent3D { + width: extent.width.max(1), + height: extent.height.max(1), + depth: 1, + }, + format: DEPTH_FORMAT, + usage: vk::ImageUsageFlags::DEPTH_STENCIL_ATTACHMENT, + image_type: vk::ImageType::TYPE_2D, + view_type: vk::ImageViewType::TYPE_2D, + aspect: vk::ImageAspectFlags::DEPTH, + }, + ) +} + +fn create_frame_state( + ctx: &Context, + swapchain_image_count: usize, +) -> Result<(Vec, Vec)> { + let device = ctx.device(); + let mut frames = Vec::with_capacity(FRAMES_IN_FLIGHT); + for _ in 0..FRAMES_IN_FLIGHT { + let pool_ci = vk::CommandPoolCreateInfo::default() + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER) + .queue_family_index(ctx.graphics_family()); + let cmd_pool = + unsafe { device.create_command_pool(&pool_ci, None) }.ctx("vkCreateCommandPool")?; + + let alloc_ci = vk::CommandBufferAllocateInfo::default() + .command_pool(cmd_pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + let cmd = unsafe { device.allocate_command_buffers(&alloc_ci) } + .ctx("vkAllocateCommandBuffers")?[0]; + + let image_available = + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) } + .ctx("vkCreateSemaphore")?; + // Взведён при создании: первый кадр не должен ждать несуществующей отправки. + let fence_ci = vk::FenceCreateInfo::default().flags(vk::FenceCreateFlags::SIGNALED); + let in_flight = + unsafe { device.create_fence(&fence_ci, None) }.ctx("vkCreateFence")?; + + frames.push(FrameSlot { + cmd_pool, + cmd, + image_available, + in_flight, + textures_to_free: Vec::new(), + }); + } + + // По одному семафору «кадр готов» на образ цепочки: он взводится при показе. + let mut render_finished = Vec::with_capacity(swapchain_image_count); + for _ in 0..swapchain_image_count { + render_finished.push( + unsafe { device.create_semaphore(&vk::SemaphoreCreateInfo::default(), None) } + .ctx("vkCreateSemaphore")?, + ); + } + + Ok((frames, render_finished)) +} diff --git a/rust/crates/simv-vk/src/shader.rs b/rust/crates/simv-vk/src/shader.rs new file mode 100644 index 0000000..d1f746b --- /dev/null +++ b/rust/crates/simv-vk/src/shader.rs @@ -0,0 +1,55 @@ +//! Шейдерные модули — перенос `simv::vk::Shader`. +//! +//! Главное отличие от C++-версии: SPIR-V не ищется на диске, а зашит в исполняемый +//! файл на этапе сборки (см. `build.rs`). Вместе с `FindSpvPath` исчезли и +//! POST_BUILD-копирование каталога `spirv/`, и требование запускать редактор из +//! каталога бинаря, и целый класс ошибок «шейдер не найден» во время выполнения: +//! отсутствующий шейдер теперь ломает сборку, а не запуск. + +use std::io::Cursor; + +use ash::vk; + +use crate::error::{Ctx, Result}; + +/// Скомпилированный SPIR-V, вшитый в бинарь. Пути соответствуют раскладке +/// `shaders/editor/` в корне репозитория. +pub mod spirv { + pub const MESH_VERT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/mesh.vert.spv")); + pub const MESH_FRAG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/mesh.frag.spv")); + pub const GRID_VERT: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/grid.vert.spv")); + pub const GRID_FRAG: &[u8] = include_bytes!(concat!(env!("OUT_DIR"), "/editor/grid.frag.spv")); +} + +/// Владеющая обёртка над `VkShaderModule`: уничтожается на выходе из области +/// видимости, в том числе если сборка конвейера завершилась ошибкой. +pub struct ShaderModule { + device: ash::Device, + handle: vk::ShaderModule, +} + +impl ShaderModule { + pub fn new(device: &ash::Device, spirv_bytes: &[u8]) -> Result { + // `read_spv` заодно снимает вопрос выравнивания: `include_bytes!` даёт срез + // байтов, а Vulkan хочет 32-битные слова. + let words = ash::util::read_spv(&mut Cursor::new(spirv_bytes)) + .map_err(|e| crate::error::VkError::Other(format!("битый SPIR-V: {e}")))?; + let ci = vk::ShaderModuleCreateInfo::default().code(&words); + let handle = + unsafe { device.create_shader_module(&ci, None) }.ctx("vkCreateShaderModule")?; + Ok(Self { + device: device.clone(), + handle, + }) + } + + pub fn handle(&self) -> vk::ShaderModule { + self.handle + } +} + +impl Drop for ShaderModule { + fn drop(&mut self) { + unsafe { self.device.destroy_shader_module(self.handle, None) }; + } +} diff --git a/rust/crates/simv-vk/src/swapchain.rs b/rust/crates/simv-vk/src/swapchain.rs new file mode 100644 index 0000000..6cb476a --- /dev/null +++ b/rust/crates/simv-vk/src/swapchain.rs @@ -0,0 +1,182 @@ +//! Цепочка показа — перенос `simv::vk::Swapchain`. +//! +//! В C++ вся эта функция умещается в вызов `vkb::SwapchainBuilder`: формат, режим +//! показа, число образов и представления создаются за него. Здесь то же самое сделано +//! руками — выбор формата с откатом, зажим числа образов и размера по возможностям +//! поверхности, создание представлений. + +use ash::vk; + +use crate::context::Context; +use crate::error::{Ctx, Result}; + +pub struct SwapchainFrame { + pub image: vk::Image, + pub view: vk::ImageView, +} + +pub struct Swapchain { + device: ash::Device, + loader: ash::khr::swapchain::Device, + handle: vk::SwapchainKHR, + frames: Vec, + color_format: vk::Format, + extent: vk::Extent2D, +} + +impl Swapchain { + pub fn new(ctx: &Context, window: &simv_core::Window, vsync: bool) -> Result { + let mut swapchain = Self { + device: ctx.device().clone(), + loader: ctx.swapchain_loader().clone(), + handle: vk::SwapchainKHR::null(), + frames: Vec::new(), + color_format: vk::Format::UNDEFINED, + extent: vk::Extent2D::default(), + }; + swapchain.recreate(ctx, window, vsync)?; + Ok(swapchain) + } + + pub fn handle(&self) -> vk::SwapchainKHR { + self.handle + } + pub fn frames(&self) -> &[SwapchainFrame] { + &self.frames + } + pub fn color_format(&self) -> vk::Format { + self.color_format + } + pub fn extent(&self) -> vk::Extent2D { + self.extent + } + + /// Пересобрать цепочку под текущий размер окна. Старая уничтожается полностью: + /// `oldSwapchain` не используется — так же, как в C++-версии. + pub fn recreate( + &mut self, + ctx: &Context, + window: &simv_core::Window, + vsync: bool, + ) -> Result<()> { + ctx.wait_idle(); + self.destroy(); + + let surface_loader = ctx.surface_loader(); + let physical_device = ctx.physical_device(); + let surface = ctx.surface(); + + let caps = unsafe { + surface_loader.get_physical_device_surface_capabilities(physical_device, surface) + } + .ctx("vkGetPhysicalDeviceSurfaceCapabilitiesKHR")?; + let formats = + unsafe { surface_loader.get_physical_device_surface_formats(physical_device, surface) } + .ctx("vkGetPhysicalDeviceSurfaceFormatsKHR")?; + let present_modes = unsafe { + surface_loader.get_physical_device_surface_present_modes(physical_device, surface) + } + .ctx("vkGetPhysicalDeviceSurfacePresentModesKHR")?; + + // Желаемый формат тот же, что просит C++-версия; если его нет — первый доступный. + let wanted = vk::SurfaceFormatKHR { + format: vk::Format::B8G8R8A8_UNORM, + color_space: vk::ColorSpaceKHR::SRGB_NONLINEAR, + }; + let surface_format = formats + .iter() + .copied() + .find(|f| f.format == wanted.format && f.color_space == wanted.color_space) + .unwrap_or_else(|| formats[0]); + + // FIFO поддерживается всегда; MAILBOX может отсутствовать. + let desired_mode = if vsync { + vk::PresentModeKHR::FIFO + } else { + vk::PresentModeKHR::MAILBOX + }; + let present_mode = present_modes + .iter() + .copied() + .find(|&m| m == desired_mode) + .unwrap_or(vk::PresentModeKHR::FIFO); + + // `current_extent` со всеми битами в единицах означает «размер задаёт + // приложение» — тогда берём размер окна и зажимаем по пределам поверхности. + let (width, height) = window.framebuffer_size(); + let extent = if caps.current_extent.width == u32::MAX { + vk::Extent2D { + width: width.clamp(caps.min_image_extent.width, caps.max_image_extent.width), + height: height.clamp(caps.min_image_extent.height, caps.max_image_extent.height), + } + } else { + caps.current_extent + }; + + let mut image_count = caps.min_image_count + 1; + if caps.max_image_count > 0 && image_count > caps.max_image_count { + image_count = caps.max_image_count; + } + + let ci = vk::SwapchainCreateInfoKHR::default() + .surface(surface) + .min_image_count(image_count) + .image_format(surface_format.format) + .image_color_space(surface_format.color_space) + .image_extent(extent) + .image_array_layers(1) + .image_usage( + vk::ImageUsageFlags::COLOR_ATTACHMENT | vk::ImageUsageFlags::TRANSFER_DST, + ) + .image_sharing_mode(vk::SharingMode::EXCLUSIVE) + .pre_transform(caps.current_transform) + .composite_alpha(vk::CompositeAlphaFlagsKHR::OPAQUE) + .present_mode(present_mode) + .clipped(true); + + self.handle = + unsafe { self.loader.create_swapchain(&ci, None) }.ctx("vkCreateSwapchainKHR")?; + self.color_format = surface_format.format; + self.extent = extent; + + let images = unsafe { self.loader.get_swapchain_images(self.handle) } + .ctx("vkGetSwapchainImagesKHR")?; + self.frames.reserve(images.len()); + for image in images { + let view_ci = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(self.color_format) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }); + let view = unsafe { self.device.create_image_view(&view_ci, None) } + .ctx("vkCreateImageView(swapchain)")?; + self.frames.push(SwapchainFrame { image, view }); + } + + Ok(()) + } + + fn destroy(&mut self) { + unsafe { + for frame in self.frames.drain(..) { + self.device.destroy_image_view(frame.view, None); + } + if self.handle != vk::SwapchainKHR::null() { + self.loader.destroy_swapchain(self.handle, None); + self.handle = vk::SwapchainKHR::null(); + } + } + } +} + +impl Drop for Swapchain { + fn drop(&mut self) { + self.destroy(); + } +}