commit 0c36122baa073ad5a233e7a2878bf283ca4bc687 Author: Efim Beshmenev <71215959+Loochik@users.noreply.github.com> Date: Sun Jul 19 00:19:41 2026 +0300 Initial commit diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 0000000..555a5e8 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+avx2,+crt-static"] diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1198bd6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +# Rust / Cargo build output +/target/ +**/*.rs.bk + +# Local logs and temporary files +*.log +*.tmp +*.bak +*.swp +*.swo + +# Local environment files +.env +.env.* +!.env.example + +# IDE and editor state +/.idea/ +/.vscode/ +/.vs/ +*.user +*.suo +*.userosscache +*.sln.docstates + +# Operating-system metadata +Thumbs.db +Desktop.ini +.DS_Store diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..b88400a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3008 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[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 = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[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.18", +] + +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" + +[[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 = "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 = "bit-set" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34ddef2995421ab6a5c779542c81ee77c115206f4ad9d5a8e05f4ff49716a3dd" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" + +[[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 = "block2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f" +dependencies = [ + "objc2 0.5.2", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2 0.6.4", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f65693059b6b9c588b9f62fed1cedbf0a8b805631457ea162d68f0de186f3de5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89588d05638b5b4594a3348a2d6c20277e43a7f5c5202b05cc56888475a47b8" +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 = "codespan-reporting" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" +dependencies = [ + "unicode-width", +] + +[[package]] +name = "color" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ec7c5eb7a16992b1904d76c517d170ab353b0e0b3d5a0c81a8a0cd1037893cf" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +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 = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "cursor-icon" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.18", +] + +[[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", + "block2 0.6.2", + "libc", + "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 = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[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.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6758be723a3f298bbfda4db75748bc2ba0abafe096b6383c7c32da264764fbc3" +dependencies = [ + "bytemuck", + "emath", +] + +[[package]] +name = "egui" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2796c98d50b79631281d516343a6f6e93c0666462ca36e2c93b39f25d7793325" +dependencies = [ + "accesskit", + "ahash", + "bitflags 2.13.1", + "emath", + "epaint", + "itertools", + "log", + "nohash-hasher", + "profiling", + "smallvec", + "unicode-segmentation", +] + +[[package]] +name = "egui-wgpu" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2e6cfac0725563555fa4f91e9f799b9d7c6c5dd831fca6abc8234afc64b7a34" +dependencies = [ + "ahash", + "bytemuck", + "document-features", + "epaint", + "log", + "profiling", + "thiserror 2.0.18", + "type-map", + "web-time", + "wgpu", +] + +[[package]] +name = "egui-winit" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ea6bf3608db949588b95b8b341ee358d0c3f95cf4dc3f53d8d76717edee87db" +dependencies = [ + "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.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "emath" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4ec073c9898516584d8c6cfdcee95b530b3d941cd5031ef4050aa36812308b" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "epaint" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e60a8888b51da911df23918fd7301359b1d43a406a0ff3b8863af093dd7fc6c" +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.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" + +[[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.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[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.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" +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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f22fb22f065b308be0d8724e3706c7fa3fc2a6c7d6899df4cad7860e7a75436" +dependencies = [ + "bytemuck", + "serde_core", +] + +[[package]] +name = "glifo" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d99fc21d493812643aae86d53b7bbd02f376434a90317e8a790bc209fdd6605e" +dependencies = [ + "bytemuck", + "foldhash 0.2.0", + "hashbrown 0.17.1", + "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", + "hashbrown 0.16.1", + "log", + "presser", + "thiserror 2.0.18", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.13.1", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "guillotiere" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" +dependencies = [ + "euclid", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "harfrust" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0431e8e389aa0f1e72bb9d1c2db8957a1a7a3580e8ed97db819c14837aac9b3e" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "read-fonts", + "smallvec", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jiff" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "961d16382652bfdd8c6f68b223b26a8c93e0d475c672f414411db31c6c5c900e" +dependencies = [ + "defmt", + "jiff-static", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[package]] +name = "jiff-static" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0879bd39df99c4c5e2c6615ccc026391a423dde10532c573e6086eb94a802cc" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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.18", + "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", +] + +[[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", +] + +[[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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +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.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[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 = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +dependencies = [ + "bitflags 2.13.1", + "libc", + "plain", + "redox_syscall 0.9.0", +] + +[[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 = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[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 = "naga" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.13.1", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.16.1", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.18", + "unicode-ident", +] + +[[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", + "libm", +] + +[[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", +] + +[[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 0.5.1", + "libc", + "objc2 0.5.2", + "objc2-core-data", + "objc2-core-image", + "objc2-foundation 0.2.2", + "objc2-quartz-core 0.2.2", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "objc2 0.6.4", + "objc2-foundation 0.3.2", +] + +[[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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "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 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2 0.5.1", + "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 0.5.1", + "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", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2 0.5.1", + "objc2 0.5.2", + "objc2-app-kit 0.2.2", + "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 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", +] + +[[package]] +name = "objc2-metal" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-foundation 0.3.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 0.5.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-metal 0.2.2", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.13.1", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", +] + +[[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 0.5.1", + "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 0.2.2", + "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 0.5.1", + "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 0.5.1", + "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 = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "orbclient" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" +dependencies = [ + "libc", + "libredox", +] + +[[package]] +name = "ordered-float" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7d950ca161dc355eaf28f82b11345ed76c6e1f6eb1f4f4479e0323b9e2fbd0e" +dependencies = [ + "num-traits", +] + +[[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", +] + +[[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.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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 = "pollster" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3" + +[[package]] +name = "polycool" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50596ddc09eb5ad5f75cacd40209568e66df71baf86e1499a0e99c4cff12a5a6" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "portable-atomic" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +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.39.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" +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 = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40d213455a5f1dc59214213c7330e074ddf8114c9a42411eb890c767357ce135" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", +] + +[[package]] +name = "ray-marching-editor" +version = "0.1.0" +dependencies = [ + "anyhow", + "bytemuck", + "egui", + "egui-wgpu", + "egui-winit", + "env_logger", + "glam", + "log", + "naga", + "pollster", + "rfd", + "serde", + "serde_json", + "thiserror 2.0.18", + "wgpu", + "winit", +] + +[[package]] +name = "read-fonts" +version = "0.39.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +dependencies = [ + "bytemuck", + "font-types", +] + +[[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.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rfd" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20dafead71c16a34e1ff357ddefc8afc11e7d51d6d2b9fbd07eaa48e3e540220" +dependencies = [ + "block2 0.6.2", + "dispatch2", + "js-sys", + "libc", + "log", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "percent-encoding", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[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.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[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 = "skrifa" +version = "0.42.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +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 = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[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 = "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.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[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", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[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 = "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.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +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 = "type-map" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90" +dependencies = [ + "rustc-hash 2.1.3", +] + +[[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 = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" + +[[package]] +name = "vello_common" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d672facaa2d697285a786cd9d44d614cd2ce54cdc022504bf339f8fff3b750" +dependencies = [ + "bytemuck", + "fearless_simd", + "guillotiere", + "hashbrown 0.17.1", + "log", + "peniko", + "smallvec", + "thiserror 2.0.18", +] + +[[package]] +name = "vello_cpu" +version = "0.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "588691169aed86b5c8fb487266afee01323234e6fd0a3f2aaec0eaa8e4007f23" +dependencies = [ + "bytemuck", + "glifo", + "hashbrown 0.17.1", + "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.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wayland-backend" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +dependencies = [ + "cc", + "downcast-rs", + "rustix 1.1.4", + "scoped-tls", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +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.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +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 = "wgpu" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "js-sys", + "log", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.16.1", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.18", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-naga-bridge", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bitflags 2.13.1", + "bytemuck", + "cfg-if", + "cfg_aliases", + "gpu-allocator", + "gpu-descriptor", + "hashbrown 0.16.1", + "libc", + "libloading", + "log", + "naga", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.18", + "wgpu-naga-bridge", + "wgpu-types", + "windows", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" +dependencies = [ + "naga", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "web-sys", +] + +[[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" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections", + "windows-core", + "windows-future", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core", +] + +[[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-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core", + "windows-link", + "windows-threading", +] + +[[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", +] + +[[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", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core", + "windows-link", +] + +[[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-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link", +] + +[[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 0.5.1", + "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 0.2.2", + "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.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" + +[[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.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..1f4ea4c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "ray-marching-editor" +version = "0.1.0" +edition = "2024" +rust-version = "1.92" +description = "A small Vulkan-only SDF ray-marching level editor" +license = "MIT OR Apache-2.0" + +[dependencies] +anyhow = "1.0.103" +bytemuck = { version = "1.25.1", features = ["derive"] } +egui = "0.35.0" +egui-wgpu = { version = "0.35.0", default-features = false } +egui-winit = { version = "0.35.0", default-features = false } +env_logger = "0.11.8" +glam = { version = "0.33.2", features = ["bytemuck", "serde"] } +log = "0.4.29" +pollster = "0.4.0" +rfd = { version = "0.17.2", default-features = false } +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.149" +thiserror = "2.0.18" +wgpu = { version = "29.0.0", default-features = false, features = ["std", "vulkan", "wgsl"] } +winit = "0.30.13" + +[dev-dependencies] +naga = { version = "29.0.4", features = ["spv-out", "wgsl-in"] } + +[profile.dev] +opt-level = 1 + +[profile.dev.package."*"] +opt-level = 2 + +[profile.release] +opt-level = 3 +lto = "thin" +codegen-units = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..02bf03c --- /dev/null +++ b/README.md @@ -0,0 +1,98 @@ +# Ray Marching Editor + +Небольшой редактор уровней на Rust с ray-marching рендерером. На desktop +рендер запускается только через Vulkan backend `wgpu`; внешний Vulkan SDK и +компилятор GLSL не нужны — шейдер хранится в WGSL и проверяется Naga. + +## Что уже есть + +- Полноэкранный ray marcher с нормалями, мягкими тенями, AO, туманом и ACES + tonemapping. +- 16 SDF-примитивов: Sphere, Box, Rounded Box, Box Frame, Plane, Torus, Link, + Capsule, Cylinder, Capped Cone, Rounded Cone, Ellipsoid, Octahedron, + Triangular Prism, Hexagonal Prism и Black Hole. +- Размещаемая чёрная дыра: абсолютно чёрный горизонт событий и настраиваемые + радиус влияния, сила линзирования и закручивание света вокруг локальной оси Z. + У выбранной чёрной дыры показывается фиолетовая граница области влияния. +- Conservative bounding sphere перед вычислением основного SDF. +- Sparse CPU uniform grid и компактная GPU-сетка до `32 × 32 × 32`; переходы + между ячейками ограничивают шаг луча, поэтому объекты в соседних ячейках не + пропускаются. Plane всегда обрабатывается глобально. +- Русский интерфейс сцены с аутлайнером, инспектором и интерактивным transform gizmo: + оси и плоскости перемещения, кольца вращения, осевое и равномерное масштабирование. + Drag вычисляется пересечениями мировых лучей с осями/плоскостями, а переключатель + «Мир / Локально» меняет и геометрию гизмо, и саму трансформацию. +- Система координат как в UE: `X/Y` образуют горизонтальную плоскость, `Z` — высота. +- Групповое выделение: `Ctrl` во вьюпорте, `Ctrl`/`Shift` в аутлайнере; команды + перемещения, вращения, масштабирования, дублирования и удаления работают со всей группой. +- Размещаемые источники «Солнце», «Лампочка» (`Point Light`) и «Прожектор» (`Spot Light`). + У выбранной лампочки виден сферический радиус действия, у прожектора — внешний конус. + Для источников доступны цвет, мощность, дальность и, у прожектора, углы конуса. +- Процедурное небо с движущимися фоновыми облаками. +- Компактная панель в углу вьюпорта в стиле `stat fps` + `stat unit`: + FPS, Frame, Game, Draw и реальный GPU time через неблокирующие timestamp queries. +- Версионированные JSON-карты со стабильными ID, источниками, материалами, + настройками мира и editor camera. «Открыть» и «Сохранить как» используют + системный Проводник; обычное сохранение повторно пишет в выбранный файл. + Запись идёт через временный файл и атомарную замену. +- VSync по умолчанию выключен: Vulkan выбирает `Immediate`, затем `Mailbox`; + в панели инструментов режим можно переключить. + +## Сборка + +Целевая конфигурация — Windows x86-64 с CPU, поддерживающим AVX2, и актуальным +Vulkan-драйвером: + +```powershell +cargo build --release +``` + +Release-профиль использует `opt-level = 3`, thin LTO, один codegen unit, +`+avx2` и статический MSVC CRT (`+crt-static`). Rust-библиотеки упаковываются в +исполняемый файл. Системный Vulkan loader `vulkan-1.dll` и видеодрайвер остаются +внешними системными компонентами. + +Готовый файл: `target\release\ray-marching-editor.exe`. + +## Управление + +| Действие | Управление | +|---|---| +| Обзор | RMB + мышь | +| Полёт | RMB + W/A/S/D, Q/E вниз/вверх | +| Ускорение | Shift | +| Скорость камеры | Колесо | +| Orbit | Alt + LMB | +| Pan | MMB | +| Dolly | Alt + RMB | +| Выделить объект / снять всё кликом по пустому месту | LMB | +| Добавить объект в выделение или убрать из него | Ctrl+LMB во вьюпорте | +| Групповое выделение в аутлайнере | Ctrl+клик; Shift+клик для диапазона | +| Взаимодействовать с гизмо | LMB по оси, плоскости, кольцу или центральному маркеру | +| Фокус на выделенном | F | +| Move / Rotate / Scale | W / E / R | +| Дублировать | Ctrl+D | +| Удалить | Delete | +| Сохранить (с выбором файла для новой карты) | Ctrl+S | +| Сохранить как | Ctrl+Shift+S | +| Открыть карту | Ctrl+O | +| Новая карта | Ctrl+N | + +Примитивы и свет добавляются кнопками `+ Примитив` и `+ Свет`. Числовые +параметры, трансформация, материал, освещение и облака редактируются в панели «Свойства». + +## Статические проверки + +```powershell +cargo fmt --all -- --check +cargo clippy --all-targets --all-features -- -D warnings +cargo test +cargo check --release +``` + +Тесты отдельно парсят WGSL, валидируют все entry points и генерируют Vulkan +SPIR-V, проверяют все 16 CPU SDF, bounds, grid, picking и round-trip карты. + +Текущие GPU-лимиты MVP: 128 видимых примитивов, 64 уникальных материала, +16 источников света и 131072 ссылок примитивов в spatial grid. При переполнении +сетки рендерер безопасно переключается на brute-force путь. diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..11270fb --- /dev/null +++ b/src/app.rs @@ -0,0 +1,781 @@ +use std::{path::PathBuf, sync::Arc, time::Instant}; + +use anyhow::{Context, Result}; +use egui::Rect; +use glam::{Quat, Vec2, Vec3}; +use winit::{ + application::ApplicationHandler, + dpi::{LogicalSize, PhysicalPosition}, + event::{DeviceEvent, ElementState, MouseButton as WinitMouseButton, WindowEvent}, + event_loop::{ActiveEventLoop, ControlFlow, EventLoop}, + keyboard::{KeyCode, ModifiersState, PhysicalKey}, + window::{CursorGrabMode, Window, WindowId}, +}; + +use crate::{ + editor::{InputState, MouseButton, SceneEditorState, SceneObjectId, SelectionMode}, + io::{choose_map_open_path, choose_map_save_path, load_map, save_map}, + renderer::{ + FLAG_AO, FLAG_CLOUDS, FLAG_FOG, FLAG_SELECTED_HIGHLIGHT, FLAG_SHADOWS, FLAG_USE_GRID, + GlobalsGpu, GpuRenderer, SceneGpuUpload, ViewportRectPx, + }, + scene::{LightKind, Material, Scene, SdfPrimitive, Transform}, + stats::FrameStats, + ui::{EditorUi, EditorUiOutput, UiAction}, +}; + +pub fn run() -> Result<()> { + let event_loop = EventLoop::new().context("failed to create the window event loop")?; + event_loop.set_control_flow(ControlFlow::Poll); + let mut app = RayMarchingEditorApp::default(); + event_loop + .run_app(&mut app) + .context("the editor event loop stopped unexpectedly")?; + if let Some(error) = app.fatal_error { + return Err(error); + } + Ok(()) +} + +#[derive(Default)] +struct RayMarchingEditorApp { + runtime: Option, + fatal_error: Option, +} + +impl ApplicationHandler for RayMarchingEditorApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.runtime.is_some() { + return; + } + match Runtime::new(event_loop) { + Ok(runtime) => self.runtime = Some(runtime), + Err(error) => { + self.fatal_error = Some(error); + event_loop.exit(); + } + } + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let Some(runtime) = self.runtime.as_mut() else { + return; + }; + if runtime.window.id() != window_id { + return; + } + + let egui_response = runtime.egui_state.on_window_event(&runtime.window, &event); + runtime.track_window_event(&event, egui_response.consumed); + + match event { + WindowEvent::CloseRequested => event_loop.exit(), + WindowEvent::Resized(size) => runtime.renderer.resize(size), + WindowEvent::RedrawRequested => { + if let Err(error) = runtime.redraw() { + runtime.ui.status = format!("Ошибка рендера: {error:#}"); + log::error!("frame failed: {error:#}"); + } + runtime.window.request_redraw(); + } + _ => {} + } + } + + fn device_event( + &mut self, + _event_loop: &ActiveEventLoop, + _device_id: winit::event::DeviceId, + event: DeviceEvent, + ) { + if let Some(runtime) = self.runtime.as_mut() + && runtime.cursor_grabbed + { + runtime.input.handle_device_event(&event); + } + } + + fn about_to_wait(&mut self, _event_loop: &ActiveEventLoop) { + if let Some(runtime) = &self.runtime { + runtime.window.request_redraw(); + } + } +} + +struct Runtime { + window: Arc, + renderer: GpuRenderer, + egui_context: egui::Context, + egui_state: egui_winit::State, + egui_renderer: egui_wgpu::Renderer, + scene: Scene, + current_map_path: Option, + editor: SceneEditorState, + input: InputState, + ui: EditorUi, + viewport: Rect, + viewport_hovered: bool, + cursor_position_px: PhysicalPosition, + cursor_grabbed: bool, + modifiers: ModifiersState, + pending_action: Option, + stats: FrameStats, + last_frame: Instant, + started_at: Instant, +} + +impl Runtime { + fn new(event_loop: &ActiveEventLoop) -> Result { + let attributes = Window::default_attributes() + .with_title("Редактор Ray Marching — Vulkan") + .with_inner_size(LogicalSize::new(1600.0, 900.0)) + .with_min_inner_size(LogicalSize::new(1024.0, 640.0)) + .with_visible(false); + let window = Arc::new( + event_loop + .create_window(attributes) + .context("failed to create the editor window")?, + ); + let renderer = pollster::block_on(GpuRenderer::new(window.clone()))?; + log::info!("using Vulkan adapter: {}", renderer.adapter_name()); + + let egui_context = egui::Context::default(); + egui_context.set_visuals(egui::Visuals::dark()); + let egui_state = egui_winit::State::new( + egui_context.clone(), + egui::ViewportId::ROOT, + window.as_ref(), + Some(window.scale_factor() as f32), + window.theme(), + Some(renderer.device().limits().max_texture_dimension_2d as usize), + ); + let egui_renderer = egui_wgpu::Renderer::new( + renderer.device(), + renderer.format(), + egui_wgpu::RendererOptions::default(), + ); + + let scene = starter_scene(); + let mut editor = SceneEditorState::default(); + editor.restore_camera_from_scene(&scene); + let mut ui = EditorUi::default(); + ui.vsync = renderer.vsync_enabled(); + ui.status = if ui.vsync { + format!( + "Новая карта · режим без VSync недоступен · {:?}", + renderer.present_mode() + ) + } else { + format!( + "Новая карта · VSync выключен · {:?}", + renderer.present_mode() + ) + }; + let now = Instant::now(); + let size = window.inner_size(); + window.set_title("Новая карта — Редактор Ray Marching — Vulkan"); + window.set_visible(true); + + Ok(Self { + window, + renderer, + egui_context, + egui_state, + egui_renderer, + scene, + current_map_path: None, + editor, + input: InputState::default(), + ui, + viewport: Rect::from_min_max( + egui::Pos2::ZERO, + egui::pos2(size.width as f32, size.height as f32), + ), + viewport_hovered: false, + cursor_position_px: PhysicalPosition::new(0.0, 0.0), + cursor_grabbed: false, + modifiers: ModifiersState::default(), + pending_action: None, + stats: FrameStats::default(), + last_frame: now, + started_at: now, + }) + } + + fn track_window_event(&mut self, event: &WindowEvent, egui_consumed: bool) { + match event { + WindowEvent::CursorMoved { position, .. } => { + self.cursor_position_px = *position; + } + WindowEvent::ModifiersChanged(modifiers) => { + self.modifiers = modifiers.state(); + } + WindowEvent::KeyboardInput { + event, + is_synthetic: false, + .. + } if event.state == ElementState::Pressed + && !event.repeat + && self.modifiers.control_key() => + { + self.pending_action = match event.physical_key { + PhysicalKey::Code(KeyCode::KeyS) if self.modifiers.shift_key() => { + Some(UiAction::SaveMapAs) + } + PhysicalKey::Code(KeyCode::KeyS) => Some(UiAction::SaveMap), + PhysicalKey::Code(KeyCode::KeyO) => Some(UiAction::LoadMap), + PhysicalKey::Code(KeyCode::KeyN) => Some(UiAction::NewMap), + _ => self.pending_action, + }; + } + WindowEvent::MouseInput { + state, + button: WinitMouseButton::Right, + .. + } => { + if *state == ElementState::Pressed && self.cursor_in_viewport() { + self.grab_cursor(); + } else if *state == ElementState::Released && self.cursor_grabbed { + self.release_cursor(); + } + } + WindowEvent::Focused(false) if self.cursor_grabbed => self.release_cursor(), + _ => {} + } + + let release_event = matches!( + event, + WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Released + ) || matches!( + event, + WindowEvent::MouseInput { + state: ElementState::Released, + .. + } + ); + let pointer_event = matches!( + event, + WindowEvent::CursorMoved { .. } + | WindowEvent::MouseInput { .. } + | WindowEvent::MouseWheel { .. } + ); + let editor_owns_event = release_event + || self.cursor_grabbed + || (pointer_event && self.cursor_in_viewport()) + || (!pointer_event && !egui_consumed); + + if editor_owns_event { + if self.cursor_grabbed && matches!(event, WindowEvent::CursorMoved { .. }) { + // Captured navigation uses raw DeviceEvent deltas only. + } else { + self.input.handle_window_event(event); + } + } else if matches!(event, WindowEvent::Focused(false)) { + self.input.handle_window_event(event); + } + } + + fn cursor_in_viewport(&self) -> bool { + let scale = self.window.scale_factor() as f32; + self.viewport.contains(egui::pos2( + self.cursor_position_px.x as f32 / scale, + self.cursor_position_px.y as f32 / scale, + )) + } + + fn grab_cursor(&mut self) { + let grabbed = self + .window + .set_cursor_grab(CursorGrabMode::Locked) + .or_else(|_| self.window.set_cursor_grab(CursorGrabMode::Confined)) + .is_ok(); + if grabbed { + self.cursor_grabbed = true; + self.window.set_cursor_visible(false); + self.input.reset_cursor_position(); + } + } + + fn release_cursor(&mut self) { + let _ = self.window.set_cursor_grab(CursorGrabMode::None); + self.window.set_cursor_visible(true); + self.cursor_grabbed = false; + self.input.reset_cursor_position(); + } + + fn redraw(&mut self) -> Result<()> { + let frame_started = Instant::now(); + let gpu_ms = self.renderer.poll_gpu_time(); + let now = frame_started; + let delta = now.duration_since(self.last_frame); + self.last_frame = now; + let delta_seconds = delta.as_secs_f32().clamp(1.0e-6, 0.25); + + let input = self.input.take_frame(); + let viewport_size = Vec2::new(self.viewport.width(), self.viewport.height()); + if !self.egui_context.egui_wants_keyboard_input() { + self.editor.handle_shortcuts(&mut self.scene, &input); + } + if self.cursor_grabbed || self.viewport_hovered || input.is_navigating() { + self.editor + .update_navigation(&input, delta_seconds, viewport_size.y); + } + self.update_viewport_interaction(&input, viewport_size); + + let raw_input = self.egui_state.take_egui_input(&self.window); + let mut ui_output: Option = None; + let full_output = self.egui_context.run_ui(raw_input, |root_ui| { + ui_output = Some( + self.ui + .show(root_ui, &mut self.scene, &mut self.editor, &self.stats), + ); + }); + let ui_output = ui_output.expect("the root egui pass always runs"); + self.viewport = ui_output.viewport; + self.viewport_hovered = ui_output.viewport_hovered; + self.egui_state + .handle_platform_output(&self.window, full_output.platform_output); + + let action = self.pending_action.take().or(ui_output.action); + if let Some(action) = action { + self.apply_ui_action(action); + } + + let game_seconds = frame_started.elapsed().as_secs_f32(); + let draw_started = Instant::now(); + let pixels_per_point = full_output.pixels_per_point; + let viewport_px = viewport_to_pixels(self.viewport, pixels_per_point, self.renderer.size()); + let mut upload = SceneGpuUpload::from_scene(&self.scene); + for primitive in &mut upload.primitives { + let selected = self.editor.selection.ids().iter().any(|object| { + matches!(object, SceneObjectId::Primitive(_)) + && object.entity_id() == primitive.meta[2] + }); + if selected { + primitive.meta[3] |= 2; + } + } + let grid = self + .ui + .use_grid + .then(|| upload.pack_spatial_grid(&self.scene, 4.0)) + .flatten(); + let globals = self.build_globals(viewport_px, grid); + self.renderer.upload(globals, &upload)?; + + let frame = match self.renderer.acquire_frame() { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => frame, + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { + self.renderer.reconfigure(); + return Ok(()); + } + wgpu::CurrentSurfaceTexture::Validation => { + anyhow::bail!("Vulkan surface validation failed"); + } + }; + let target = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + + for (id, image_delta) in &full_output.textures_delta.set { + self.egui_renderer.update_texture( + self.renderer.device(), + self.renderer.queue(), + *id, + image_delta, + ); + } + let paint_jobs = self + .egui_context + .tessellate(full_output.shapes, pixels_per_point); + let screen = egui_wgpu::ScreenDescriptor { + size_in_pixels: [self.renderer.size().width, self.renderer.size().height], + pixels_per_point, + }; + self.renderer.prepare_gpu_timing(); + let mut encoder = self.renderer.create_encoder(); + let callback_buffers = self.egui_renderer.update_buffers( + self.renderer.device(), + self.renderer.queue(), + &mut encoder, + &paint_jobs, + &screen, + ); + self.renderer + .encode_scene(&mut encoder, &target, viewport_px); + { + let timestamp_writes = self.renderer.gpu_frame_end_timestamp_writes(); + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("editor UI"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + let mut pass = pass.forget_lifetime(); + self.egui_renderer.render(&mut pass, &paint_jobs, &screen); + } + self.renderer.resolve_gpu_timing(&mut encoder); + let frame_commands = encoder.finish(); + self.renderer + .queue() + .submit(callback_buffers.into_iter().chain([frame_commands])); + self.renderer.map_gpu_timing_after_submit(); + frame.present(); + for id in &full_output.textures_delta.free { + self.egui_renderer.free_texture(id); + } + self.stats.record_frame( + delta.as_secs_f32(), + game_seconds, + draw_started.elapsed().as_secs_f32(), + gpu_ms, + ); + Ok(()) + } + + fn update_viewport_interaction(&mut self, input: &crate::editor::InputFrame, size: Vec2) { + let left_down = input.mouse_down(MouseButton::Left); + let scale = self.window.scale_factor() as f32; + let local_cursor = input.cursor_position.map(|cursor_px| { + cursor_px / scale - Vec2::new(self.viewport.min.x, self.viewport.min.y) + }); + + if self.editor.is_transform_dragging() { + if left_down { + if let Some(local_cursor) = local_cursor { + self.editor + .update_gizmo_drag(&mut self.scene, local_cursor, size); + } + } else { + self.editor.end_gizmo_drag(); + } + return; + } + + if !self.viewport_hovered { + self.editor.clear_gizmo_hover(); + return; + } + let Some(local_cursor) = local_cursor else { + self.editor.clear_gizmo_hover(); + return; + }; + + self.editor + .update_gizmo_hover(&self.scene, local_cursor, size); + if input.modifiers.alt || !input.mouse_pressed(MouseButton::Left) { + return; + } + + // The gizmo owns the click before scene picking, exactly as an editor + // transform widget should. Object bodies only change selection. + if self + .editor + .begin_gizmo_drag(&self.scene, local_cursor, size) + .is_some() + { + return; + } + + let selection_mode = if input.modifiers.control { + SelectionMode::Toggle + } else { + SelectionMode::Replace + }; + self.editor + .select_at_with_mode(&self.scene, local_cursor, size, selection_mode); + } + + fn apply_ui_action(&mut self, action: UiAction) { + match action { + UiAction::NewMap => { + self.scene = starter_scene(); + self.editor = SceneEditorState::default(); + self.editor.restore_camera_from_scene(&self.scene); + self.current_map_path = None; + self.update_window_title(); + self.ui.status = "Создана новая несохранённая карта".into(); + } + UiAction::SaveMap => { + if let Some(path) = self.current_map_path.clone() { + self.save_to_path(path); + } else { + self.save_as(); + } + } + UiAction::SaveMapAs => self.save_as(), + UiAction::LoadMap => { + let selected = + choose_map_open_path(self.window.as_ref(), self.current_map_path.as_deref()); + if let Some(path) = selected { + match load_map(&path) { + Ok(scene) => { + self.scene = scene; + self.editor = SceneEditorState::default(); + self.editor.restore_camera_from_scene(&self.scene); + self.current_map_path = Some(path.clone()); + self.update_window_title(); + self.ui.status = format!("Карта загружена: {}", path.display()); + } + Err(error) => { + self.ui.status = format!("Ошибка загрузки: {error}"); + } + } + } else { + self.ui.status = "Открытие карты отменено".into(); + } + } + UiAction::SetVsync(enabled) => { + let honored = self.renderer.set_vsync(enabled); + self.ui.vsync = self.renderer.vsync_enabled(); + self.ui.status = if honored { + format!( + "VSync {} · режим {:?}", + if enabled { + "включён" + } else { + "выключен" + }, + self.renderer.present_mode() + ) + } else { + format!( + "Драйвер не поддерживает запрошенный режим VSync · используется {:?}", + self.renderer.present_mode() + ) + }; + } + } + } + + fn save_as(&mut self) { + let selected = choose_map_save_path(self.window.as_ref(), self.current_map_path.as_deref()); + if let Some(path) = selected { + self.save_to_path(path); + } else { + self.ui.status = "Сохранение карты отменено".into(); + } + } + + fn save_to_path(&mut self, path: PathBuf) { + self.editor.store_camera_in_scene(&mut self.scene); + match save_map(&path, &self.scene) { + Ok(()) => { + self.current_map_path = Some(path.clone()); + self.update_window_title(); + self.ui.status = format!("Карта сохранена: {}", path.display()); + } + Err(error) => self.ui.status = format!("Ошибка сохранения: {error}"), + } + } + + fn update_window_title(&self) { + let map_name = self + .current_map_path + .as_deref() + .and_then(std::path::Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or("Новая карта"); + self.window + .set_title(&format!("{map_name} — Редактор Ray Marching — Vulkan")); + } + + fn build_globals( + &self, + viewport: ViewportRectPx, + grid: Option, + ) -> GlobalsGpu { + let aspect = viewport.width.max(1) as f32 / viewport.height.max(1) as f32; + let mut globals = GlobalsGpu { + inv_view_proj: self + .editor + .camera + .view_projection_matrix(aspect) + .inverse() + .to_cols_array_2d(), + camera_pos_time: [ + self.editor.camera.position.x, + self.editor.camera.position.y, + self.editor.camera.position.z, + self.started_at.elapsed().as_secs_f32(), + ], + viewport_maxdist_epsilon: [ + viewport.width as f32, + viewport.height as f32, + self.editor.raymarch.max_distance, + self.editor.raymarch.hit_epsilon, + ], + sun_color_ambient: [ + self.scene.lighting.ambient_color.x, + self.scene.lighting.ambient_color.y, + self.scene.lighting.ambient_color.z, + self.scene.lighting.ambient_intensity, + ], + fog_color_density: [ + self.scene.background_color.x, + self.scene.background_color.y, + self.scene.background_color.z, + 0.008, + ], + raymarch_params: [ + 0.82, + self.scene.lighting.shadow_softness, + 0.65, + self.scene.clouds.coverage, + ], + ..GlobalsGpu::default() + }; + + if let Some(sun) = self + .scene + .lights() + .iter() + .find(|light| light.enabled && matches!(light.kind, LightKind::DirectionalSun)) + { + let to_sun = -sun.direction(); + globals.sun_dir_intensity = [to_sun.x, to_sun.y, to_sun.z, sun.intensity]; + } + + let mut flags = 0; + if self.ui.shadows { + flags |= FLAG_SHADOWS; + } + if self.ui.ambient_occlusion { + flags |= FLAG_AO; + } + if self.ui.fog { + flags |= FLAG_FOG; + } + if self.ui.clouds && self.scene.clouds.enabled { + flags |= FLAG_CLOUDS; + } + if let Some(selected) = self.editor.selection.active() { + globals.counts_flags[2] = selected.entity_id(); + } + if self + .editor + .selection + .ids() + .iter() + .any(|selected| matches!(selected, SceneObjectId::Primitive(_))) + { + flags |= FLAG_SELECTED_HIGHLIGHT; + } + if let Some(grid) = grid { + flags |= FLAG_USE_GRID; + globals.grid_origin_cell_size = + [grid.origin.x, grid.origin.y, grid.origin.z, grid.cell_size]; + globals.grid_dims_steps = [ + grid.dimensions.x, + grid.dimensions.y, + grid.dimensions.z, + self.editor.raymarch.max_steps.min(192), + ]; + } else { + globals.grid_dims_steps[3] = self.editor.raymarch.max_steps.min(192); + } + globals.counts_flags[3] = flags; + globals + } +} + +fn viewport_to_pixels( + rect: Rect, + pixels_per_point: f32, + target: winit::dpi::PhysicalSize, +) -> ViewportRectPx { + ViewportRectPx { + x: (rect.min.x * pixels_per_point).round().max(0.0) as u32, + y: (rect.min.y * pixels_per_point).round().max(0.0) as u32, + width: (rect.width() * pixels_per_point).round().max(1.0) as u32, + height: (rect.height() * pixels_per_point).round().max(1.0) as u32, + } + .clamped(target) +} + +fn starter_scene() -> Scene { + let mut scene = Scene::default(); + scene.name = "Карта Ray Marching".into(); + + let ground_material = Material { + albedo: Vec3::new(0.18, 0.21, 0.25), + roughness: 0.9, + ..Material::default() + }; + scene.add_primitive_with( + "Земля", + SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.0, + }, + Transform::default(), + ground_material, + ); + scene.add_primitive_with( + "Рубиновая сфера", + SdfPrimitive::Sphere { radius: 1.0 }, + Transform::from_translation(Vec3::new(-1.7, 0.0, 1.0)), + Material { + albedo: Vec3::new(0.72, 0.08, 0.11), + roughness: 0.3, + metallic: 0.08, + ..Material::default() + }, + ); + scene.add_primitive_with( + "Скруглённый блок", + SdfPrimitive::RoundedBox { + half_extents: Vec3::new(1.0, 1.0, 1.0), + radius: 0.22, + }, + Transform { + translation: Vec3::new(1.45, 0.1, 1.0), + rotation: Quat::from_rotation_z(-0.35), + scale: Vec3::ONE, + }, + Material { + albedo: Vec3::new(0.08, 0.32, 0.72), + roughness: 0.42, + metallic: 0.2, + ..Material::default() + }, + ); + scene.add_primitive_with( + "Латунный тор", + SdfPrimitive::Torus { + major_radius: 1.0, + minor_radius: 0.25, + }, + Transform { + translation: Vec3::new(0.0, -2.4, 1.3), + rotation: Quat::from_rotation_x(0.6), + scale: Vec3::ONE, + }, + Material { + albedo: Vec3::new(0.62, 0.36, 0.08), + roughness: 0.22, + metallic: 0.82, + ..Material::default() + }, + ); + scene +} diff --git a/src/editor/camera.rs b/src/editor/camera.rs new file mode 100644 index 0000000..d3e9247 --- /dev/null +++ b/src/editor/camera.rs @@ -0,0 +1,253 @@ +use glam::{Mat4, Vec2, Vec3}; + +const MIN_NEAR: f32 = 0.000_1; +const MIN_FOV: f32 = 1.0_f32.to_radians(); +const MAX_FOV: f32 = 175.0_f32.to_radians(); +const PITCH_LIMIT: f32 = 89.5_f32.to_radians(); + +/// Editor world convention: X points right, Y points forward and Z is height. +pub const WORLD_UP: Vec3 = Vec3::Z; +pub const WORLD_FORWARD: Vec3 = Vec3::Y; + +/// Perspective settings kept separate from the renderer's GPU camera data. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct CameraProjection { + pub vertical_fov: f32, + pub near: f32, + pub far: f32, +} + +impl Default for CameraProjection { + fn default() -> Self { + Self { + vertical_fov: 60.0_f32.to_radians(), + near: 0.05, + far: 10_000.0, + } + } +} + +/// A simple right-handed Z-up editor camera. Yaw zero looks along world +Y. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Camera { + pub position: Vec3, + pub yaw: f32, + pub pitch: f32, + pub projection: CameraProjection, +} + +impl Default for Camera { + fn default() -> Self { + Self { + position: Vec3::new(7.0, -9.0, 5.0), + yaw: -38.0_f32.to_radians(), + pitch: -18.0_f32.to_radians(), + projection: CameraProjection::default(), + } + } +} + +/// World-space ray cast from a viewport pixel. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct ViewportRay { + pub origin: Vec3, + pub direction: Vec3, +} + +/// Bounds used by the editor's `F` (focus selection) command. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FocusTarget { + pub center: Vec3, + pub radius: f32, +} + +impl FocusTarget { + pub fn new(center: Vec3, radius: f32) -> Self { + Self { + center, + radius: radius.max(0.001), + } + } +} + +impl Camera { + pub fn new(position: Vec3, yaw: f32, pitch: f32) -> Self { + let mut camera = Self { + position, + yaw, + pitch, + projection: CameraProjection::default(), + }; + camera.sanitize_angles(); + camera + } + + /// Unit vector in the direction the camera is looking. + pub fn forward(&self) -> Vec3 { + let (sin_yaw, cos_yaw) = self.yaw.sin_cos(); + let (sin_pitch, cos_pitch) = self.pitch.sin_cos(); + Vec3::new(sin_yaw * cos_pitch, cos_yaw * cos_pitch, sin_pitch).normalize() + } + + pub fn right(&self) -> Vec3 { + self.forward().cross(WORLD_UP).normalize() + } + + pub fn up(&self) -> Vec3 { + self.right().cross(self.forward()).normalize() + } + + pub fn view_matrix(&self) -> Mat4 { + glam::camera::rh::view::look_to_mat4(self.position, self.forward(), self.up()) + } + + /// Right-handed projection with a Vulkan/WebGPU [0, 1] depth range. + pub fn projection_matrix(&self, aspect_ratio: f32) -> Mat4 { + let projection = self.sanitized_projection(); + glam::camera::rh::proj::directx::perspective( + projection.vertical_fov, + aspect_ratio.max(0.001), + projection.near, + projection.far, + ) + } + + pub fn view_projection_matrix(&self, aspect_ratio: f32) -> Mat4 { + self.projection_matrix(aspect_ratio) * self.view_matrix() + } + + pub fn rotate(&mut self, yaw_delta: f32, pitch_delta: f32) { + self.yaw += yaw_delta; + self.pitch += pitch_delta; + self.sanitize_angles(); + } + + pub fn look_at(&mut self, target: Vec3) { + let direction = (target - self.position).normalize_or_zero(); + if direction.length_squared() <= f32::EPSILON { + return; + } + self.pitch = direction.z.clamp(-1.0, 1.0).asin(); + self.yaw = direction.x.atan2(direction.y); + self.sanitize_angles(); + } + + /// Move far enough back to fit a sphere in view while preserving orientation. + pub fn focus(&mut self, target: FocusTarget, margin: f32) -> f32 { + let half_fov = self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV) * 0.5; + let distance = target.radius * margin.max(1.0) / half_fov.sin().max(0.001); + self.position = target.center - self.forward() * distance; + distance + } + + /// Convert a top-left-origin viewport coordinate to a normalized world ray. + pub fn viewport_ray(&self, pixel: Vec2, viewport_size: Vec2) -> Option { + if !pixel.is_finite() + || !viewport_size.is_finite() + || viewport_size.x <= 0.0 + || viewport_size.y <= 0.0 + { + return None; + } + + let ndc = Vec2::new( + pixel.x.mul_add(2.0 / viewport_size.x, -1.0), + 1.0 - pixel.y * (2.0 / viewport_size.y), + ); + let tan_half_fov = (self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV) * 0.5).tan(); + let aspect = viewport_size.x / viewport_size.y; + let direction = (self.forward() + + self.right() * (ndc.x * aspect * tan_half_fov) + + self.up() * (ndc.y * tan_half_fov)) + .normalize(); + + Some(ViewportRay { + origin: self.position, + direction, + }) + } + + fn sanitized_projection(&self) -> CameraProjection { + let near = self.projection.near.max(MIN_NEAR); + CameraProjection { + vertical_fov: self.projection.vertical_fov.clamp(MIN_FOV, MAX_FOV), + near, + far: self.projection.far.max(near + MIN_NEAR), + } + } + + fn sanitize_angles(&mut self) { + self.pitch = self.pitch.clamp(-PITCH_LIMIT, PITCH_LIMIT); + self.yaw = wrap_angle(self.yaw); + } +} + +fn wrap_angle(angle: f32) -> f32 { + (angle + std::f32::consts::PI).rem_euclid(std::f32::consts::TAU) - std::f32::consts::PI +} + +#[cfg(test)] +mod tests { + use super::*; + + const EPSILON: f32 = 1.0e-5; + + #[test] + fn default_coordinate_basis_is_orthonormal() { + let camera = Camera::new(Vec3::ZERO, 0.0, 0.0); + assert!(camera.forward().abs_diff_eq(Vec3::Y, EPSILON)); + assert!(camera.right().abs_diff_eq(Vec3::X, EPSILON)); + assert!(camera.up().abs_diff_eq(Vec3::Z, EPSILON)); + assert!(camera.forward().dot(camera.right()).abs() < EPSILON); + assert!(camera.forward().dot(camera.up()).abs() < EPSILON); + assert!(camera.right().dot(camera.up()).abs() < EPSILON); + } + + #[test] + fn pitch_is_clamped_away_from_the_poles() { + let mut camera = Camera::new(Vec3::ZERO, 0.0, 0.0); + camera.rotate(0.0, std::f32::consts::PI); + assert!(camera.pitch < std::f32::consts::FRAC_PI_2); + assert!(camera.forward().is_finite()); + assert!(camera.right().is_finite()); + } + + #[test] + fn viewport_center_ray_matches_forward() { + let camera = Camera::new(Vec3::new(1.0, 2.0, 3.0), 0.7, -0.2); + let ray = camera + .viewport_ray(Vec2::new(640.0, 360.0), Vec2::new(1280.0, 720.0)) + .unwrap(); + assert_eq!(ray.origin, camera.position); + assert!(ray.direction.abs_diff_eq(camera.forward(), EPSILON)); + } + + #[test] + fn focus_places_target_on_forward_axis() { + let mut camera = Camera::new(Vec3::ZERO, 0.4, -0.1); + let target = FocusTarget::new(Vec3::new(3.0, 1.0, -4.0), 2.0); + let distance = camera.focus(target, 1.25); + let offset = target.center - camera.position; + assert!((offset.length() - distance).abs() < EPSILON); + assert!(offset.normalize().abs_diff_eq(camera.forward(), EPSILON)); + } + + #[test] + fn look_at_uses_z_as_height_and_preserves_an_orthonormal_basis() { + let mut camera = Camera::new(Vec3::new(4.0, -7.0, 3.0), 0.0, 0.0); + let target = Vec3::new(-2.0, 5.0, 9.0); + camera.look_at(target); + + assert!( + camera + .forward() + .abs_diff_eq((target - camera.position).normalize(), EPSILON) + ); + assert!( + camera + .right() + .abs_diff_eq(camera.forward().cross(Vec3::Z).normalize(), EPSILON) + ); + assert!(camera.up().z > 0.0); + } +} diff --git a/src/editor/fps.rs b/src/editor/fps.rs new file mode 100644 index 0000000..d3ba9b3 --- /dev/null +++ b/src/editor/fps.rs @@ -0,0 +1,59 @@ +use std::time::Duration; + +/// Minimal per-frame timing state for the viewport overlay. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct FpsCounter { + fps: f32, + frame_time_ms: f32, +} + +impl FpsCounter { + /// Record a completed frame. The displayed value changes every frame and + /// intentionally has no smoothing or publication delay. + pub fn record_frame(&mut self, delta: Duration) -> f32 { + self.record_seconds(delta.as_secs_f32()) + } + + pub fn record_seconds(&mut self, seconds: f32) -> f32 { + if seconds.is_finite() && seconds > 0.0 { + self.fps = seconds.recip(); + self.frame_time_ms = seconds * 1000.0; + } + self.fps + } + + pub fn fps(&self) -> f32 { + self.fps + } + + pub fn frame_time_ms(&self) -> f32 { + self.frame_time_ms + } + + pub fn reset(&mut self) { + *self = Self::default(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reports_inverse_of_latest_frame_delta() { + let mut counter = FpsCounter::default(); + assert!((counter.record_seconds(1.0 / 60.0) - 60.0).abs() < 0.001); + assert!((counter.record_seconds(1.0 / 120.0) - 120.0).abs() < 0.001); + assert!((counter.frame_time_ms() - 1000.0 / 120.0).abs() < 0.001); + } + + #[test] + fn invalid_samples_leave_last_value_unchanged() { + let mut counter = FpsCounter::default(); + counter.record_seconds(0.02); + let before = counter; + counter.record_seconds(0.0); + counter.record_seconds(f32::NAN); + assert_eq!(counter, before); + } +} diff --git a/src/editor/gizmo.rs b/src/editor/gizmo.rs new file mode 100644 index 0000000..0a4f022 --- /dev/null +++ b/src/editor/gizmo.rs @@ -0,0 +1,582 @@ +use glam::{Quat, Vec2, Vec3, Vec4}; + +use super::{Axis, Camera, TransformMode}; + +/// A two-axis translation constraint. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Plane { + XY, + XZ, + YZ, +} + +impl Plane { + pub const fn axes(self) -> (Axis, Axis) { + match self { + Self::XY => (Axis::X, Axis::Y), + Self::XZ => (Axis::X, Axis::Z), + Self::YZ => (Axis::Y, Axis::Z), + } + } + + pub(crate) const fn indices(self) -> (usize, usize) { + match self { + Self::XY => (0, 1), + Self::XZ => (0, 2), + Self::YZ => (1, 2), + } + } +} + +/// A concrete interactive part of the transform gizmo. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum GizmoHandle { + TranslateAxis(Axis), + TranslatePlane(Plane), + RotateAxis(Axis), + ScaleAxis(Axis), + ScaleUniform, +} + +impl GizmoHandle { + pub const fn mode(self) -> TransformMode { + match self { + Self::TranslateAxis(_) | Self::TranslatePlane(_) => TransformMode::Translate, + Self::RotateAxis(_) => TransformMode::Rotate, + Self::ScaleAxis(_) | Self::ScaleUniform => TransformMode::Scale, + } + } + + pub const fn axis(self) -> Option { + match self { + Self::TranslateAxis(axis) | Self::RotateAxis(axis) | Self::ScaleAxis(axis) => { + Some(axis) + } + Self::TranslatePlane(_) | Self::ScaleUniform => None, + } + } +} + +/// Result of screen-space gizmo hit-testing. Smaller distance wins when parts +/// overlap; plane and uniform handles report zero while the pointer is inside. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GizmoHit { + pub handle: GizmoHandle, + pub distance_pixels: f32, +} + +/// Pixel dimensions are deliberately independent of camera distance, like the +/// transform widget in conventional level editors. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GizmoConfig { + pub axis_length_pixels: f32, + pub axis_pick_radius_pixels: f32, + pub plane_inner_pixels: f32, + pub plane_outer_pixels: f32, + pub ring_radius_pixels: f32, + pub ring_pick_radius_pixels: f32, + pub uniform_pick_radius_pixels: f32, +} + +impl Default for GizmoConfig { + fn default() -> Self { + Self { + axis_length_pixels: 82.0, + axis_pick_radius_pixels: 7.0, + plane_inner_pixels: 18.0, + plane_outer_pixels: 34.0, + ring_radius_pixels: 64.0, + ring_pick_radius_pixels: 7.0, + uniform_pick_radius_pixels: 10.0, + } + } +} + +pub(crate) fn hit_test( + camera: &Camera, + pivot: Vec3, + orientation: Quat, + mode: TransformMode, + pixel: Vec2, + viewport_size: Vec2, + config: GizmoConfig, +) -> Option { + if !pixel.is_finite() + || !viewport_size.is_finite() + || viewport_size.x <= 0.0 + || viewport_size.y <= 0.0 + { + return None; + } + let pivot_screen = project_world(camera, pivot, viewport_size)?; + let world_scale = world_scale(camera, pivot, viewport_size.y, config.axis_length_pixels)?; + let basis = basis(orientation); + + match mode { + TransformMode::Translate => hit_translate( + camera, + pivot, + pivot_screen, + basis, + world_scale, + pixel, + viewport_size, + config, + ), + TransformMode::Rotate => hit_rotate( + camera, + pivot, + basis, + world_scale, + pixel, + viewport_size, + config, + ), + TransformMode::Scale => hit_scale( + camera, + pivot, + pivot_screen, + basis, + world_scale, + pixel, + viewport_size, + config, + ), + } +} + +#[allow(clippy::too_many_arguments)] +fn hit_translate( + camera: &Camera, + pivot: Vec3, + pivot_screen: Vec2, + basis: [Vec3; 3], + world_scale: f32, + pixel: Vec2, + viewport_size: Vec2, + config: GizmoConfig, +) -> Option { + let screens = projected_axis_endpoints(camera, pivot, basis, world_scale, viewport_size); + + // Plane handles get priority over axes when their filled areas overlap. + for plane in [Plane::XY, Plane::XZ, Plane::YZ] { + let (a, b) = plane.indices(); + let Some(axis_a) = screens[a].map(|end| end - pivot_screen) else { + continue; + }; + let Some(axis_b) = screens[b].map(|end| end - pivot_screen) else { + continue; + }; + let Some(dir_a) = normalized_screen_axis(axis_a) else { + continue; + }; + let Some(dir_b) = normalized_screen_axis(axis_b) else { + continue; + }; + let inner = config.plane_inner_pixels; + let outer = config.plane_outer_pixels; + let quad = [ + pivot_screen + dir_a * inner + dir_b * inner, + pivot_screen + dir_a * outer + dir_b * inner, + pivot_screen + dir_a * outer + dir_b * outer, + pivot_screen + dir_a * inner + dir_b * outer, + ]; + if polygon_area(&quad).abs() >= 12.0 && point_in_convex_quad(pixel, quad) { + return Some(GizmoHit { + handle: GizmoHandle::TranslatePlane(plane), + distance_pixels: 0.0, + }); + } + } + + closest_axis_hit( + pivot_screen, + screens, + pixel, + config.axis_pick_radius_pixels, + GizmoHandle::TranslateAxis, + ) +} + +#[allow(clippy::too_many_arguments)] +fn hit_scale( + camera: &Camera, + pivot: Vec3, + pivot_screen: Vec2, + basis: [Vec3; 3], + world_scale: f32, + pixel: Vec2, + viewport_size: Vec2, + config: GizmoConfig, +) -> Option { + if pixel.distance(pivot_screen) <= config.uniform_pick_radius_pixels { + return Some(GizmoHit { + handle: GizmoHandle::ScaleUniform, + distance_pixels: pixel.distance(pivot_screen), + }); + } + let screens = projected_axis_endpoints(camera, pivot, basis, world_scale, viewport_size); + closest_axis_hit( + pivot_screen, + screens, + pixel, + config.axis_pick_radius_pixels, + GizmoHandle::ScaleAxis, + ) +} + +fn hit_rotate( + camera: &Camera, + pivot: Vec3, + basis: [Vec3; 3], + world_scale: f32, + pixel: Vec2, + viewport_size: Vec2, + config: GizmoConfig, +) -> Option { + const SEGMENTS: usize = 72; + let radius = world_scale * config.ring_radius_pixels / config.axis_length_pixels.max(1.0); + let ring_bases = [ + (basis[1], basis[2]), + (basis[2], basis[0]), + (basis[0], basis[1]), + ]; + let axes = [Axis::X, Axis::Y, Axis::Z]; + let mut best: Option = None; + + for (axis_index, (u, v)) in ring_bases.into_iter().enumerate() { + let mut previous = project_world(camera, pivot + u * radius, viewport_size); + let mut minimum = f32::INFINITY; + for segment in 1..=SEGMENTS { + let angle = segment as f32 * std::f32::consts::TAU / SEGMENTS as f32; + let point = pivot + (u * angle.cos() + v * angle.sin()) * radius; + let current = project_world(camera, point, viewport_size); + if let (Some(from), Some(to)) = (previous, current) { + minimum = minimum.min(distance_to_segment(pixel, from, to)); + } + previous = current; + } + if minimum <= config.ring_pick_radius_pixels + && best.is_none_or(|candidate| minimum < candidate.distance_pixels) + { + best = Some(GizmoHit { + handle: GizmoHandle::RotateAxis(axes[axis_index]), + distance_pixels: minimum, + }); + } + } + best +} + +fn closest_axis_hit( + pivot_screen: Vec2, + screens: [Option; 3], + pixel: Vec2, + tolerance: f32, + make_handle: impl Fn(Axis) -> GizmoHandle, +) -> Option { + let axes = [Axis::X, Axis::Y, Axis::Z]; + let mut best: Option = None; + for (index, endpoint) in screens.into_iter().enumerate() { + let Some(endpoint) = endpoint else { + continue; + }; + let start = pivot_screen.lerp(endpoint, 0.13); + let distance = distance_to_segment(pixel, start, endpoint); + if distance <= tolerance + && best.is_none_or(|candidate| distance < candidate.distance_pixels) + { + best = Some(GizmoHit { + handle: make_handle(axes[index]), + distance_pixels: distance, + }); + } + } + best +} + +pub(crate) fn basis(orientation: Quat) -> [Vec3; 3] { + [ + (orientation * Vec3::X).normalize_or_zero(), + (orientation * Vec3::Y).normalize_or_zero(), + (orientation * Vec3::Z).normalize_or_zero(), + ] +} + +pub(crate) fn world_scale( + camera: &Camera, + pivot: Vec3, + viewport_height: f32, + pixel_length: f32, +) -> Option { + if !pivot.is_finite() || !viewport_height.is_finite() || viewport_height <= 0.0 { + return None; + } + let distance = (pivot - camera.position).dot(camera.forward()); + if !distance.is_finite() || distance <= camera.projection.near { + return None; + } + let units_per_pixel = + 2.0 * distance * (camera.projection.vertical_fov * 0.5).tan() / viewport_height; + Some((units_per_pixel * pixel_length).max(0.000_1)) +} + +pub(crate) fn project_world(camera: &Camera, world: Vec3, viewport_size: Vec2) -> Option { + if !world.is_finite() + || !viewport_size.is_finite() + || viewport_size.x <= 0.0 + || viewport_size.y <= 0.0 + { + return None; + } + let view_projection = camera.view_projection_matrix(viewport_size.x / viewport_size.y); + let clip: Vec4 = view_projection * world.extend(1.0); + if !clip.is_finite() || clip.w <= 0.001 { + return None; + } + let ndc = clip.truncate().truncate() / clip.w; + Some(Vec2::new( + (ndc.x * 0.5 + 0.5) * viewport_size.x, + (0.5 - ndc.y * 0.5) * viewport_size.y, + )) +} + +fn projected_axis_endpoints( + camera: &Camera, + pivot: Vec3, + basis: [Vec3; 3], + world_scale: f32, + viewport_size: Vec2, +) -> [Option; 3] { + basis.map(|axis| project_world(camera, pivot + axis * world_scale, viewport_size)) +} + +fn normalized_screen_axis(axis: Vec2) -> Option { + (axis.length_squared() >= 4.0).then(|| axis.normalize()) +} + +fn distance_to_segment(point: Vec2, from: Vec2, to: Vec2) -> f32 { + let segment = to - from; + let length_squared = segment.length_squared(); + if length_squared <= f32::EPSILON { + return point.distance(from); + } + let t = ((point - from).dot(segment) / length_squared).clamp(0.0, 1.0); + point.distance(from + segment * t) +} + +fn polygon_area(points: &[Vec2; 4]) -> f32 { + let mut area = 0.0; + for index in 0..4 { + let next = (index + 1) % 4; + area += points[index].x * points[next].y - points[next].x * points[index].y; + } + area * 0.5 +} + +fn point_in_convex_quad(point: Vec2, quad: [Vec2; 4]) -> bool { + let mut sign = 0.0_f32; + for index in 0..4 { + let edge = quad[(index + 1) % 4] - quad[index]; + let to_point = point - quad[index]; + let cross = edge.perp_dot(to_point); + if cross.abs() <= 0.001 { + continue; + } + if sign == 0.0 { + sign = cross.signum(); + } else if sign * cross < 0.0 { + return false; + } + } + sign != 0.0 +} + +#[cfg(test)] +mod tests { + use super::*; + + fn test_camera() -> Camera { + Camera::new(Vec3::new(0.0, -5.0, 0.0), 0.0, 0.0) + } + + #[test] + fn translate_axes_and_plane_are_screen_pickable() { + let camera = test_camera(); + let viewport = Vec2::new(800.0, 600.0); + let config = GizmoConfig::default(); + let x_axis = hit_test( + &camera, + Vec3::ZERO, + Quat::IDENTITY, + TransformMode::Translate, + Vec2::new(460.0, 300.0), + viewport, + config, + ) + .unwrap(); + assert_eq!(x_axis.handle, GizmoHandle::TranslateAxis(Axis::X)); + + let xz_plane = hit_test( + &camera, + Vec3::ZERO, + Quat::IDENTITY, + TransformMode::Translate, + Vec2::new(426.0, 274.0), + viewport, + config, + ) + .unwrap(); + assert_eq!(xz_plane.handle, GizmoHandle::TranslatePlane(Plane::XZ)); + } + + #[test] + fn rotate_ring_and_scale_uniform_are_screen_pickable() { + let camera = test_camera(); + let viewport = Vec2::new(800.0, 600.0); + let config = GizmoConfig::default(); + let ring = hit_test( + &camera, + Vec3::ZERO, + Quat::IDENTITY, + TransformMode::Rotate, + Vec2::new(445.25, 254.75), + viewport, + config, + ) + .unwrap(); + assert_eq!(ring.handle, GizmoHandle::RotateAxis(Axis::Y)); + + let uniform = hit_test( + &camera, + Vec3::ZERO, + Quat::IDENTITY, + TransformMode::Scale, + Vec2::new(400.0, 300.0), + viewport, + config, + ) + .unwrap(); + assert_eq!(uniform.handle, GizmoHandle::ScaleUniform); + } + + #[test] + fn every_required_axis_plane_ring_and_uniform_handle_can_be_hit() { + let mut camera = Camera::new(Vec3::new(4.0, 3.0, 5.0), 0.0, 0.0); + camera.look_at(Vec3::ZERO); + let viewport = Vec2::new(1_000.0, 800.0); + let config = GizmoConfig::default(); + let pivot = Vec3::ZERO; + let pivot_screen = project_world(&camera, pivot, viewport).unwrap(); + let scale = world_scale(&camera, pivot, viewport.y, config.axis_length_pixels).unwrap(); + let world_basis = basis(Quat::IDENTITY); + let endpoints = projected_axis_endpoints(&camera, pivot, world_basis, scale, viewport); + let axes = [Axis::X, Axis::Y, Axis::Z]; + + for (index, axis) in axes.into_iter().enumerate() { + let pointer = pivot_screen.lerp(endpoints[index].unwrap(), 0.82); + let translate = hit_test( + &camera, + pivot, + Quat::IDENTITY, + TransformMode::Translate, + pointer, + viewport, + config, + ) + .unwrap(); + assert_eq!(translate.handle, GizmoHandle::TranslateAxis(axis)); + + let scale_axis = hit_test( + &camera, + pivot, + Quat::IDENTITY, + TransformMode::Scale, + pointer, + viewport, + config, + ) + .unwrap(); + assert_eq!(scale_axis.handle, GizmoHandle::ScaleAxis(axis)); + } + + for plane in [Plane::XY, Plane::XZ, Plane::YZ] { + let (a, b) = plane.indices(); + let direction_a = (endpoints[a].unwrap() - pivot_screen).normalize(); + let direction_b = (endpoints[b].unwrap() - pivot_screen).normalize(); + let mut found = false; + 'search: for along_a in 20..=32 { + for along_b in 20..=32 { + let pointer = + pivot_screen + direction_a * along_a as f32 + direction_b * along_b as f32; + if hit_test( + &camera, + pivot, + Quat::IDENTITY, + TransformMode::Translate, + pointer, + viewport, + config, + ) + .is_some_and(|hit| hit.handle == GizmoHandle::TranslatePlane(plane)) + { + found = true; + break 'search; + } + } + } + assert!(found, "translation plane {plane:?} was not pickable"); + } + + let radius = scale * config.ring_radius_pixels / config.axis_length_pixels; + let ring_bases = [ + (world_basis[1], world_basis[2]), + (world_basis[2], world_basis[0]), + (world_basis[0], world_basis[1]), + ]; + for (index, axis) in axes.into_iter().enumerate() { + let (u, v) = ring_bases[index]; + let mut found = false; + for step in 0..360 { + let angle = step as f32 * std::f32::consts::TAU / 360.0; + let Some(pointer) = project_world( + &camera, + pivot + (u * angle.cos() + v * angle.sin()) * radius, + viewport, + ) else { + continue; + }; + if hit_test( + &camera, + pivot, + Quat::IDENTITY, + TransformMode::Rotate, + pointer, + viewport, + config, + ) + .is_some_and(|hit| hit.handle == GizmoHandle::RotateAxis(axis)) + { + found = true; + break; + } + } + assert!(found, "rotation ring {axis:?} was not pickable"); + } + + assert_eq!( + hit_test( + &camera, + pivot, + Quat::IDENTITY, + TransformMode::Scale, + pivot_screen, + viewport, + config, + ) + .unwrap() + .handle, + GizmoHandle::ScaleUniform + ); + } +} diff --git a/src/editor/input.rs b/src/editor/input.rs new file mode 100644 index 0000000..fcb6ecd --- /dev/null +++ b/src/editor/input.rs @@ -0,0 +1,450 @@ +use glam::{Vec2, Vec3}; +use winit::{ + event::{DeviceEvent, ElementState, MouseScrollDelta, WindowEvent}, + keyboard::{KeyCode, PhysicalKey}, +}; + +use super::{Camera, WORLD_UP}; + +const KEY_COUNT: usize = 9; +const MOUSE_COUNT: usize = 3; + +/// Physical editor keys. W/E are interpreted contextually: camera movement +/// while navigating and gizmo shortcuts otherwise. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum EditorKey { + W, + A, + S, + D, + Q, + E, + R, + F, + Delete, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[repr(u8)] +pub enum MouseButton { + Left, + Middle, + Right, +} + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct Modifiers { + pub alt: bool, + pub control: bool, + pub shift: bool, +} + +/// Immutable input snapshot consumed once by the editor each frame. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct InputFrame { + keys_down: [bool; KEY_COUNT], + keys_pressed: [bool; KEY_COUNT], + mouse_down: [bool; MOUSE_COUNT], + mouse_pressed: [bool; MOUSE_COUNT], + pub modifiers: Modifiers, + pub mouse_delta: Vec2, + pub cursor_position: Option, + pub wheel_steps: f32, +} + +impl Default for InputFrame { + fn default() -> Self { + Self { + keys_down: [false; KEY_COUNT], + keys_pressed: [false; KEY_COUNT], + mouse_down: [false; MOUSE_COUNT], + mouse_pressed: [false; MOUSE_COUNT], + modifiers: Modifiers::default(), + mouse_delta: Vec2::ZERO, + cursor_position: None, + wheel_steps: 0.0, + } + } +} + +impl InputFrame { + pub fn key_down(&self, key: EditorKey) -> bool { + self.keys_down[key as usize] + } + + pub fn key_pressed(&self, key: EditorKey) -> bool { + self.keys_pressed[key as usize] + } + + pub fn mouse_down(&self, button: MouseButton) -> bool { + self.mouse_down[button as usize] + } + + pub fn mouse_pressed(&self, button: MouseButton) -> bool { + self.mouse_pressed[button as usize] + } + + pub fn is_navigating(&self) -> bool { + self.mouse_down(MouseButton::Right) + || self.mouse_down(MouseButton::Middle) + || (self.modifiers.alt && self.mouse_down(MouseButton::Left)) + } +} + +/// Stateful event accumulator. It is deliberately separate from editor logic, +/// so an egui panel or another frontend can feed the same controls manually. +#[derive(Debug, Clone)] +pub struct InputState { + keys_down: [bool; KEY_COUNT], + keys_pressed: [bool; KEY_COUNT], + mouse_down: [bool; MOUSE_COUNT], + mouse_pressed: [bool; MOUSE_COUNT], + modifiers: Modifiers, + mouse_delta: Vec2, + cursor_position: Option, + wheel_steps: f32, +} + +impl Default for InputState { + fn default() -> Self { + Self { + keys_down: [false; KEY_COUNT], + keys_pressed: [false; KEY_COUNT], + mouse_down: [false; MOUSE_COUNT], + mouse_pressed: [false; MOUSE_COUNT], + modifiers: Modifiers::default(), + mouse_delta: Vec2::ZERO, + cursor_position: None, + wheel_steps: 0.0, + } + } +} + +impl InputState { + pub fn set_key(&mut self, key: EditorKey, pressed: bool) { + let index = key as usize; + if pressed && !self.keys_down[index] { + self.keys_pressed[index] = true; + } + self.keys_down[index] = pressed; + } + + pub fn set_mouse_button(&mut self, button: MouseButton, pressed: bool) { + let index = button as usize; + if pressed && !self.mouse_down[index] { + self.mouse_pressed[index] = true; + } + self.mouse_down[index] = pressed; + } + + pub fn set_modifiers(&mut self, modifiers: Modifiers) { + self.modifiers = modifiers; + } + + pub fn add_mouse_delta(&mut self, delta: Vec2) { + if delta.is_finite() { + self.mouse_delta += delta; + } + } + + pub fn set_cursor_position(&mut self, position: Vec2) { + if !position.is_finite() { + return; + } + if let Some(previous) = self.cursor_position { + self.mouse_delta += position - previous; + } + self.cursor_position = Some(position); + } + + pub fn add_wheel_steps(&mut self, steps: f32) { + if steps.is_finite() { + self.wheel_steps += steps; + } + } + + /// Consume transient deltas/presses while retaining held state. + pub fn take_frame(&mut self) -> InputFrame { + let frame = InputFrame { + keys_down: self.keys_down, + keys_pressed: self.keys_pressed, + mouse_down: self.mouse_down, + mouse_pressed: self.mouse_pressed, + modifiers: self.modifiers, + mouse_delta: self.mouse_delta, + cursor_position: self.cursor_position, + wheel_steps: self.wheel_steps, + }; + self.keys_pressed.fill(false); + self.mouse_pressed.fill(false); + self.mouse_delta = Vec2::ZERO; + self.wheel_steps = 0.0; + frame + } + + pub fn clear_held(&mut self) { + self.keys_down.fill(false); + self.mouse_down.fill(false); + self.keys_pressed.fill(false); + self.mouse_pressed.fill(false); + self.mouse_delta = Vec2::ZERO; + } + + /// Forget the last absolute cursor sample when entering or leaving cursor + /// grab, preventing the first unlocked event from producing a large jump. + pub fn reset_cursor_position(&mut self) { + self.cursor_position = None; + self.mouse_delta = Vec2::ZERO; + } + + /// Convenience bridge for winit 0.30. Returns true when the event belongs + /// to the editor controls. Raw mouse motion can be passed to + /// [`Self::handle_device_event`] while the cursor is grabbed. + pub fn handle_window_event(&mut self, event: &WindowEvent) -> bool { + match event { + WindowEvent::KeyboardInput { event, .. } => { + let PhysicalKey::Code(code) = event.physical_key else { + return false; + }; + let Some(key) = editor_key(code) else { + return false; + }; + self.set_key(key, event.state == ElementState::Pressed); + true + } + WindowEvent::MouseInput { state, button, .. } => { + let button = match button { + winit::event::MouseButton::Left => MouseButton::Left, + winit::event::MouseButton::Middle => MouseButton::Middle, + winit::event::MouseButton::Right => MouseButton::Right, + _ => return false, + }; + self.set_mouse_button(button, *state == ElementState::Pressed); + true + } + WindowEvent::MouseWheel { delta, .. } => { + let steps = match delta { + MouseScrollDelta::LineDelta(_, y) => *y, + MouseScrollDelta::PixelDelta(position) => position.y as f32 / 100.0, + }; + self.add_wheel_steps(steps); + true + } + WindowEvent::CursorMoved { position, .. } => { + self.set_cursor_position(Vec2::new(position.x as f32, position.y as f32)); + true + } + WindowEvent::ModifiersChanged(modifiers) => { + let state = modifiers.state(); + self.set_modifiers(Modifiers { + alt: state.alt_key(), + control: state.control_key(), + shift: state.shift_key(), + }); + true + } + WindowEvent::Focused(false) => { + self.clear_held(); + true + } + _ => false, + } + } + + pub fn handle_device_event(&mut self, event: &DeviceEvent) -> bool { + if let DeviceEvent::MouseMotion { delta } = event { + self.add_mouse_delta(Vec2::new(delta.0 as f32, delta.1 as f32)); + true + } else { + false + } + } +} + +fn editor_key(code: KeyCode) -> Option { + Some(match code { + KeyCode::KeyW => EditorKey::W, + KeyCode::KeyA => EditorKey::A, + KeyCode::KeyS => EditorKey::S, + KeyCode::KeyD => EditorKey::D, + KeyCode::KeyQ => EditorKey::Q, + KeyCode::KeyE => EditorKey::E, + KeyCode::KeyR => EditorKey::R, + KeyCode::KeyF => EditorKey::F, + KeyCode::Delete => EditorKey::Delete, + _ => return None, + }) +} + +/// UE-style viewport navigation operating only on a camera and an input frame. +#[derive(Debug, Clone, Copy)] +pub struct NavigationController { + pub move_speed: f32, + pub min_move_speed: f32, + pub max_move_speed: f32, + pub speed_step: f32, + pub fast_multiplier: f32, + pub look_sensitivity: f32, + pub orbit_sensitivity: f32, + pub pan_sensitivity: f32, + pub dolly_sensitivity: f32, + pub orbit_pivot: Vec3, + pub orbit_distance: f32, +} + +impl Default for NavigationController { + fn default() -> Self { + Self { + move_speed: 6.0, + min_move_speed: 0.05, + max_move_speed: 2_000.0, + speed_step: 1.2, + fast_multiplier: 4.0, + look_sensitivity: 0.0025, + orbit_sensitivity: 0.004, + pan_sensitivity: 1.0, + dolly_sensitivity: 0.012, + orbit_pivot: Vec3::ZERO, + orbit_distance: 10.0, + } + } +} + +impl NavigationController { + pub fn set_orbit_target(&mut self, camera: &Camera, pivot: Vec3) { + self.orbit_pivot = pivot; + self.orbit_distance = camera.position.distance(pivot).max(0.01); + } + + pub fn update( + &mut self, + camera: &mut Camera, + input: &InputFrame, + delta_seconds: f32, + viewport_height: f32, + ) { + if input.wheel_steps != 0.0 { + self.move_speed = (self.move_speed * self.speed_step.powf(input.wheel_steps)) + .clamp(self.min_move_speed, self.max_move_speed); + } + + let mouse_delta = input.mouse_delta; + let alt = input.modifiers.alt; + let left = input.mouse_down(MouseButton::Left); + let middle = input.mouse_down(MouseButton::Middle); + let right = input.mouse_down(MouseButton::Right); + + if alt && left { + camera.rotate( + mouse_delta.x * self.orbit_sensitivity, + -mouse_delta.y * self.orbit_sensitivity, + ); + camera.position = self.orbit_pivot - camera.forward() * self.orbit_distance; + } else if middle { + let world_per_pixel = + world_units_per_pixel(camera, self.orbit_distance, viewport_height) + * self.pan_sensitivity; + let translation = camera.right() * (-mouse_delta.x * world_per_pixel) + + camera.up() * (mouse_delta.y * world_per_pixel); + camera.position += translation; + self.orbit_pivot += translation; + } else if alt && right { + let drag = mouse_delta.x - mouse_delta.y; + self.orbit_distance = (self.orbit_distance * (-drag * self.dolly_sensitivity).exp()) + .clamp(0.01, 1_000_000.0); + camera.position = self.orbit_pivot - camera.forward() * self.orbit_distance; + } else if right { + camera.rotate( + mouse_delta.x * self.look_sensitivity, + -mouse_delta.y * self.look_sensitivity, + ); + } + + if right && !alt && delta_seconds.is_finite() && delta_seconds > 0.0 { + let forward = bool_as_axis(input.key_down(EditorKey::W)) + - bool_as_axis(input.key_down(EditorKey::S)); + let right_axis = bool_as_axis(input.key_down(EditorKey::D)) + - bool_as_axis(input.key_down(EditorKey::A)); + let up = bool_as_axis(input.key_down(EditorKey::E)) + - bool_as_axis(input.key_down(EditorKey::Q)); + let direction = + camera.forward() * forward + camera.right() * right_axis + WORLD_UP * up; + if direction.length_squared() > 0.0 { + let multiplier = if input.modifiers.shift { + self.fast_multiplier + } else { + 1.0 + }; + let translation = + direction.normalize() * self.move_speed * multiplier * delta_seconds.min(0.25); + camera.position += translation; + self.orbit_pivot += translation; + } + } + } +} + +fn bool_as_axis(value: bool) -> f32 { + if value { 1.0 } else { 0.0 } +} + +pub(crate) fn world_units_per_pixel(camera: &Camera, distance: f32, viewport_height: f32) -> f32 { + if !viewport_height.is_finite() || viewport_height <= 0.0 { + return 0.0; + } + 2.0 * distance.max(camera.projection.near) * (camera.projection.vertical_fov * 0.5).tan() + / viewport_height +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wheel_changes_speed_with_limits() { + let mut controller = NavigationController::default(); + let mut camera = Camera::default(); + let mut frame = InputFrame { + wheel_steps: 2.0, + ..InputFrame::default() + }; + controller.update(&mut camera, &frame, 0.0, 720.0); + assert!((controller.move_speed - 6.0 * 1.2 * 1.2).abs() < 1.0e-5); + + frame.wheel_steps = -10_000.0; + controller.update(&mut camera, &frame, 0.0, 720.0); + assert_eq!(controller.move_speed, controller.min_move_speed); + } + + #[test] + fn rmb_w_moves_forward() { + let mut controller = NavigationController::default(); + let mut camera = Camera::new(Vec3::ZERO, 0.0, 0.0); + let mut frame = InputFrame::default(); + frame.mouse_down[MouseButton::Right as usize] = true; + frame.keys_down[EditorKey::W as usize] = true; + controller.update(&mut camera, &frame, 0.25, 720.0); + assert!( + camera + .position + .abs_diff_eq(Vec3::new(0.0, 1.5, 0.0), 1.0e-5) + ); + } + + #[test] + fn rmb_qe_move_only_along_world_z() { + let mut controller = NavigationController::default(); + let mut camera = Camera::new(Vec3::new(3.0, 4.0, 5.0), 0.8, -0.4); + let start = camera.position; + let mut frame = InputFrame::default(); + frame.mouse_down[MouseButton::Right as usize] = true; + frame.keys_down[EditorKey::E as usize] = true; + controller.update(&mut camera, &frame, 0.25, 720.0); + + let delta = camera.position - start; + assert!(delta.x.abs() < 1.0e-6); + assert!(delta.y.abs() < 1.0e-6); + assert!((delta.z - 1.5).abs() < 1.0e-5); + } +} diff --git a/src/editor/mod.rs b/src/editor/mod.rs new file mode 100644 index 0000000..8bb70b3 --- /dev/null +++ b/src/editor/mod.rs @@ -0,0 +1,22 @@ +//! UI-agnostic state and controls for the level editor viewport. +//! +//! The surrounding application is responsible for translating its events into +//! [`InputState`] and for drawing gizmos. This module owns the camera, selection, +//! transform commands and the small amount of timing state those views need. + +mod camera; +mod fps; +mod gizmo; +mod input; +mod scene_adapter; +mod state; + +pub use camera::{Camera, CameraProjection, FocusTarget, ViewportRay, WORLD_FORWARD, WORLD_UP}; +pub use fps::FpsCounter; +pub use gizmo::{GizmoConfig, GizmoHandle, GizmoHit, Plane}; +pub use input::{EditorKey, InputFrame, InputState, Modifiers, MouseButton, NavigationController}; +pub use scene_adapter::{SceneEditorState, SceneObjectId}; +pub use state::{ + Axis, EditableScene, EditorCommand, EditorState, EditorTransform, PickHit, PickSample, + RaymarchSettings, Selection, SelectionMode, TransformMode, TransformSpace, raymarch_scene, +}; diff --git a/src/editor/scene_adapter.rs b/src/editor/scene_adapter.rs new file mode 100644 index 0000000..c6f5f58 --- /dev/null +++ b/src/editor/scene_adapter.rs @@ -0,0 +1,226 @@ +use glam::Vec3; + +use crate::scene::{EntityId, LightId, PrimitiveId, Scene, Transform}; + +use super::{ + EditableScene, EditorState, EditorTransform, FocusTarget, PickHit, PickSample, ViewportRay, +}; + +const LIGHT_PICK_RADIUS: f32 = 0.4; +const UNBOUNDED_PRIMITIVE_FOCUS_RADIUS: f32 = 2.0; + +/// Selection discriminator for objects whose numeric IDs share one scene-wide +/// namespace. Keeping the kind explicit makes delete/duplicate unambiguous and +/// lets UI code choose the right inspector without another scene lookup. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum SceneObjectId { + Primitive(PrimitiveId), + Light(LightId), +} + +pub type SceneEditorState = EditorState; + +impl SceneObjectId { + pub fn entity_id(self) -> EntityId { + match self { + Self::Primitive(id) | Self::Light(id) => id, + } + } + + pub fn is_light(self) -> bool { + matches!(self, Self::Light(_)) + } +} + +impl EditorState { + /// Restore the viewport state stored alongside a map. + pub fn restore_camera_from_scene(&mut self, scene: &Scene) { + let saved = &scene.editor_camera; + self.camera.position = saved.position; + self.camera.yaw = saved.yaw_radians; + self.camera.pitch = saved.pitch_radians; + self.camera.projection.vertical_fov = saved.field_of_view_y_radians; + self.camera.projection.near = saved.near_plane; + self.camera.projection.far = saved.far_plane; + // Rotate by zero to apply the camera's angle wrapping and pitch limit. + self.camera.rotate(0.0, 0.0); + self.navigation.move_speed = saved.movement_speed.clamp( + self.navigation.min_move_speed, + self.navigation.max_move_speed, + ); + self.navigation + .set_orbit_target(&self.camera, saved.orbit_pivot); + } + + /// Persist camera/navigation state before serializing a map. + pub fn store_camera_in_scene(&self, scene: &mut Scene) { + let saved = &mut scene.editor_camera; + saved.position = self.camera.position; + saved.yaw_radians = self.camera.yaw; + saved.pitch_radians = self.camera.pitch; + saved.movement_speed = self.navigation.move_speed; + saved.orbit_pivot = self.navigation.orbit_pivot; + saved.field_of_view_y_radians = self.camera.projection.vertical_fov; + saved.near_plane = self.camera.projection.near; + saved.far_plane = self.camera.projection.far; + } +} + +impl EditableScene for Scene { + type Id = SceneObjectId; + + fn sample_for_pick(&self, point: Vec3) -> PickSample { + let sample = self.sample(point); + PickSample { + distance: sample.distance, + object_id: sample.primitive_id.map(SceneObjectId::Primitive), + } + } + + fn pick_auxiliary(&self, ray: ViewportRay, max_distance: f32) -> Option> { + self.lights() + .iter() + .filter_map(|light| { + let sphere = light.selection_sphere(LIGHT_PICK_RADIUS); + let distance = ray_sphere_distance(ray, sphere.center, sphere.radius)?; + (distance <= max_distance).then(|| { + let position = ray.origin + ray.direction * distance; + PickHit { + object_id: SceneObjectId::Light(light.id), + position, + normal: (position - sphere.center).normalize_or_zero(), + distance, + steps: 1, + } + }) + }) + .min_by(|left, right| left.distance.total_cmp(&right.distance)) + } + + fn editor_transform(&self, id: Self::Id) -> Option { + let transform = match id { + SceneObjectId::Primitive(id) => self.primitive(id)?.transform, + SceneObjectId::Light(id) => self.light(id)?.transform, + }; + Some(to_editor_transform(transform)) + } + + fn set_editor_transform(&mut self, id: Self::Id, transform: EditorTransform) -> bool { + let transform = to_scene_transform(transform); + match id { + SceneObjectId::Primitive(id) => { + let Some(primitive) = self.primitive_mut(id) else { + return false; + }; + primitive.transform = transform; + } + SceneObjectId::Light(id) => { + let Some(light) = self.light_mut(id) else { + return false; + }; + light.transform = transform; + } + } + true + } + + fn remove_editor_object(&mut self, id: Self::Id) -> bool { + match id { + SceneObjectId::Primitive(id) => self.remove_primitive(id).is_some(), + SceneObjectId::Light(id) => self.remove_light(id).is_some(), + } + } + + fn duplicate_editor_object(&mut self, id: Self::Id) -> Option { + match id { + SceneObjectId::Primitive(id) => { + self.duplicate_primitive(id).map(SceneObjectId::Primitive) + } + SceneObjectId::Light(id) => self.duplicate_light(id).map(SceneObjectId::Light), + } + } + + fn focus_target(&self, id: Self::Id) -> Option { + match id { + SceneObjectId::Primitive(id) => { + let primitive = self.primitive(id)?; + let (center, radius) = primitive + .shape + .bounding_sphere() + .map(|bounds| { + ( + primitive.transform.local_to_world(bounds.center), + bounds.radius * primitive.transform.maximum_abs_scale(), + ) + }) + .unwrap_or(( + primitive.transform.translation, + UNBOUNDED_PRIMITIVE_FOCUS_RADIUS, + )); + Some(FocusTarget::new(center, radius)) + } + SceneObjectId::Light(id) => { + let sphere = self.light(id)?.selection_sphere(LIGHT_PICK_RADIUS); + Some(FocusTarget::new(sphere.center, sphere.radius)) + } + } + } +} + +fn to_editor_transform(transform: Transform) -> EditorTransform { + EditorTransform { + translation: transform.translation, + rotation: transform.rotation, + scale: transform.scale, + } +} + +fn to_scene_transform(transform: EditorTransform) -> Transform { + let transform = transform.sanitized(); + Transform { + translation: transform.translation, + rotation: transform.rotation, + scale: transform.scale, + } +} + +fn ray_sphere_distance(ray: ViewportRay, center: Vec3, radius: f32) -> Option { + let direction = ray.direction.normalize_or_zero(); + let origin_to_center = ray.origin - center; + let half_b = origin_to_center.dot(direction); + let c = origin_to_center.length_squared() - radius * radius; + let discriminant = half_b * half_b - c; + if discriminant < 0.0 { + return None; + } + + let root = discriminant.sqrt(); + let near = -half_b - root; + let far = -half_b + root; + if near >= 0.0 { + Some(near) + } else if far >= 0.0 { + Some(far) + } else { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sphere_intersection_chooses_near_positive_root() { + let distance = ray_sphere_distance( + ViewportRay { + origin: Vec3::new(0.0, 0.0, 3.0), + direction: Vec3::NEG_Z, + }, + Vec3::ZERO, + 1.0, + ) + .unwrap(); + assert!((distance - 2.0).abs() < 1.0e-6); + } +} diff --git a/src/editor/state.rs b/src/editor/state.rs new file mode 100644 index 0000000..6105d5b --- /dev/null +++ b/src/editor/state.rs @@ -0,0 +1,1919 @@ +use std::{borrow::Borrow, fmt::Debug, hash::Hash}; + +use glam::{Quat, Vec2, Vec3}; + +use super::{ + Camera, FocusTarget, FpsCounter, GizmoConfig, GizmoHandle, GizmoHit, InputFrame, + NavigationController, Plane, ViewportRay, gizmo, input::world_units_per_pixel, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum TransformMode { + #[default] + Translate, + Rotate, + Scale, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum TransformSpace { + #[default] + World, + Local, +} + +/// Gizmo constraint. `View` means the camera plane for translation and scale, +/// and the view direction for rotation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum Axis { + X, + Y, + Z, + #[default] + View, +} + +/// How a selection request combines with the current ordered selection. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SelectionMode { + #[default] + Replace, + Add, + Toggle, +} + +/// Ordered, duplicate-free object selection. The active (primary) object is the +/// gizmo pivot and inspector target; normally it is the most recently selected +/// item, but callers may choose another member when replacing a range. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Selection { + ordered: Vec, + active: Option, +} + +impl Default for Selection { + fn default() -> Self { + Self { + ordered: Vec::new(), + active: None, + } + } +} + +impl Selection { + pub fn ids(&self) -> &[Id] { + &self.ordered + } + + pub fn active(&self) -> Option { + self.active + } + + /// Alias useful in frontends that call the last-selected actor "primary". + pub fn primary(&self) -> Option { + self.active + } + + pub fn contains(&self, id: Q) -> bool + where + Q: Borrow, + { + self.ordered.contains(id.borrow()) + } + + pub fn len(&self) -> usize { + self.ordered.len() + } + + pub fn is_empty(&self) -> bool { + self.ordered.is_empty() + } + + fn clear(&mut self) { + self.ordered.clear(); + self.active = None; + } + + fn replace(&mut self, ids: I, requested_active: Option) + where + I: IntoIterator, + { + self.ordered.clear(); + for id in ids { + if !self.ordered.contains(&id) { + self.ordered.push(id); + } + } + self.active = requested_active + .filter(|id| self.ordered.contains(id)) + .or_else(|| self.ordered.last().copied()); + } + + fn add(&mut self, id: Id) { + if !self.ordered.contains(&id) { + self.ordered.push(id); + } + self.active = Some(id); + } + + /// Returns true when the object remains selected and false when toggled off. + fn toggle(&mut self, id: Id) -> bool { + if let Some(index) = self.ordered.iter().position(|candidate| *candidate == id) { + self.ordered.remove(index); + if self.active == Some(id) { + self.active = self.ordered.last().copied(); + } + false + } else { + self.ordered.push(id); + self.active = Some(id); + true + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct EditorTransform { + pub translation: Vec3, + pub rotation: Quat, + pub scale: Vec3, +} + +impl Default for EditorTransform { + fn default() -> Self { + Self { + translation: Vec3::ZERO, + rotation: Quat::IDENTITY, + scale: Vec3::ONE, + } + } +} + +impl EditorTransform { + pub fn sanitized(mut self) -> Self { + if !self.translation.is_finite() { + self.translation = Vec3::ZERO; + } + if !self.rotation.is_finite() || self.rotation.length_squared() < 1.0e-10 { + self.rotation = Quat::IDENTITY; + } else { + self.rotation = self.rotation.normalize(); + } + if !self.scale.is_finite() { + self.scale = Vec3::ONE; + } + self.scale = self.scale.map(|component| { + if component.abs() >= 0.001 { + component + } else if component.is_sign_negative() { + -0.001 + } else { + 0.001 + } + }); + self + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PickSample { + pub distance: f32, + pub object_id: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct PickHit { + pub object_id: Id, + pub position: Vec3, + pub normal: Vec3, + pub distance: f32, + pub steps: u32, +} + +/// The narrow scene API required by the editor. The concrete [`crate::scene::Scene`] +/// adapter lives in `scene_adapter.rs`; tests and alternative frontends can use +/// small mock scenes without depending on renderer types. +pub trait EditableScene { + type Id: Copy + Eq + Hash + Debug; + + /// Signed distance query used for CPU picking of SDF objects. + fn sample_for_pick(&self, point: Vec3) -> PickSample; + + /// Optional analytic pick for non-SDF editor objects (lights, helpers). + fn pick_auxiliary(&self, _ray: ViewportRay, _max_distance: f32) -> Option> { + None + } + + fn editor_transform(&self, id: Self::Id) -> Option; + fn set_editor_transform(&mut self, id: Self::Id, transform: EditorTransform) -> bool; + fn remove_editor_object(&mut self, id: Self::Id) -> bool; + fn duplicate_editor_object(&mut self, id: Self::Id) -> Option; + fn focus_target(&self, id: Self::Id) -> Option; +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RaymarchSettings { + pub max_distance: f32, + pub max_steps: u32, + pub hit_epsilon: f32, + pub normal_epsilon: f32, + /// Values below this threshold still advance to avoid a stalled ray. + pub min_step: f32, +} + +impl Default for RaymarchSettings { + fn default() -> Self { + Self { + max_distance: 2_000.0, + max_steps: 256, + hit_epsilon: 0.002, + normal_epsilon: 0.001, + min_step: 0.000_25, + } + } +} + +impl RaymarchSettings { + fn sanitized(self) -> Self { + Self { + max_distance: self.max_distance.max(0.0), + max_steps: self.max_steps.max(1), + hit_epsilon: self.hit_epsilon.max(f32::EPSILON), + normal_epsilon: self.normal_epsilon.max(f32::EPSILON), + min_step: self.min_step.max(f32::EPSILON), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum EditorCommand { + FocusSelection, + DeleteSelection, + DuplicateSelection, + SetTransformMode(TransformMode), + ToggleTransformSpace, +} + +#[derive(Debug, Clone)] +struct TransformDrag { + objects: Vec<(Id, EditorTransform)>, + mode: TransformMode, + handle: GizmoHandle, + pivot: Vec3, + orientation: Quat, + accumulated_pixels: Vec2, + camera_distance: f32, + pointer_start: Option, + pointer_current: Option, + geometric_constraint: Option, +} + +/// World-space constraint captured at pointer press. Absolute drag updates only +/// cast a new viewport ray and compare it with this immutable anchor. +#[derive(Debug, Clone, Copy)] +enum GeometricConstraint { + TranslateAxis(AxisLineConstraint), + TranslatePlane { + normal: Vec3, + start_point: Vec3, + }, + RotateRing { + axis: Vec3, + start_vector: Vec3, + }, + ScaleAxis { + line: AxisLineConstraint, + reference_length: f32, + }, + ScaleUniform { + plane_normal: Vec3, + start_point: Vec3, + direction: Vec3, + reference_length: f32, + }, +} + +#[derive(Debug, Clone, Copy)] +struct AxisLineConstraint { + origin: Vec3, + direction: Vec3, + start_parameter: f32, +} + +/// Complete persistent viewport state, independent from egui/widget lifetime. +#[derive(Debug, Clone)] +pub struct EditorState { + pub camera: Camera, + pub navigation: NavigationController, + pub fps: FpsCounter, + /// Canonical ordered multi-selection. + pub selection: Selection, + /// Compatibility alias for older frontends. It is always synchronized to + /// `selection.active()` by editor selection APIs. + pub selected: Option, + pub transform_mode: TransformMode, + pub transform_space: TransformSpace, + pub raymarch: RaymarchSettings, + pub gizmo_config: GizmoConfig, + hovered_gizmo: Option, + transform_drag: Option>, +} + +impl Default for EditorState { + fn default() -> Self { + let camera = Camera::default(); + let mut navigation = NavigationController::default(); + navigation.set_orbit_target(&camera, Vec3::ZERO); + Self { + camera, + navigation, + fps: FpsCounter::default(), + selection: Selection::default(), + selected: None, + transform_mode: TransformMode::default(), + transform_space: TransformSpace::default(), + raymarch: RaymarchSettings::default(), + gizmo_config: GizmoConfig::default(), + hovered_gizmo: None, + transform_drag: None, + } + } +} + +impl EditorState +where + Id: Copy + Eq + Hash + Debug, +{ + pub fn update_navigation( + &mut self, + input: &InputFrame, + delta_seconds: f32, + viewport_height: f32, + ) { + // Gizmo drags own the pointer until released. + if self.transform_drag.is_none() { + self.navigation + .update(&mut self.camera, input, delta_seconds, viewport_height); + } + } + + pub fn pick_ray(&self, scene: &S, ray: ViewportRay) -> Option> + where + S: EditableScene, + { + let sdf_hit = raymarch_scene(scene, ray, self.raymarch); + let auxiliary_hit = scene.pick_auxiliary(ray, self.raymarch.max_distance); + match (sdf_hit, auxiliary_hit) { + (Some(sdf), Some(auxiliary)) => Some(if sdf.distance <= auxiliary.distance { + sdf + } else { + auxiliary + }), + (Some(hit), None) | (None, Some(hit)) => Some(hit), + (None, None) => None, + } + } + + /// Pick at a top-left-origin viewport pixel. Clicking empty space clears the + /// selection, matching common level-editor behavior. + pub fn select_at( + &mut self, + scene: &S, + pixel: Vec2, + viewport_size: Vec2, + ) -> Option> + where + S: EditableScene, + { + self.select_at_with_mode(scene, pixel, viewport_size, SelectionMode::Replace) + } + + /// Viewport selection with Ctrl-compatible add/toggle semantics. Empty + /// space clears only in `Replace` mode; Ctrl-clicking empty space preserves + /// the current selection. + pub fn select_at_with_mode( + &mut self, + scene: &S, + pixel: Vec2, + viewport_size: Vec2, + mode: SelectionMode, + ) -> Option> + where + S: EditableScene, + { + let hit = self + .camera + .viewport_ray(pixel, viewport_size) + .and_then(|ray| self.pick_ray(scene, ray)); + match (hit, mode) { + (Some(hit), SelectionMode::Replace) => { + self.set_selection(scene, Some(hit.object_id)); + } + (Some(hit), SelectionMode::Add) => { + self.add_to_selection(scene, hit.object_id); + } + (Some(hit), SelectionMode::Toggle) => { + self.toggle_selection(scene, hit.object_id); + } + (None, SelectionMode::Replace) => self.clear_selection(), + (None, SelectionMode::Add | SelectionMode::Toggle) => {} + } + hit + } + + /// Select from an outliner or another non-viewport source while keeping the + /// Alt+LMB orbit pivot in sync. + pub fn set_selection(&mut self, scene: &S, selection: Option) + where + S: EditableScene, + { + match selection { + Some(id) => self.set_selection_many(scene, [id], Some(id)), + None => self.clear_selection(), + } + } + + /// Replace selection with an ordered, duplicate-free list. This is the + /// range-friendly API used by Shift selection in an outliner. + pub fn set_selection_many(&mut self, scene: &S, ids: I, active: Option) + where + S: EditableScene, + I: IntoIterator, + { + let valid = ids + .into_iter() + .filter(|id| scene.editor_transform(*id).is_some()) + .collect::>(); + self.selection.replace(valid, active); + self.selection_changed(scene); + } + + pub fn add_to_selection(&mut self, scene: &S, id: Id) -> bool + where + S: EditableScene, + { + if scene.editor_transform(id).is_none() { + return false; + } + let was_selected = self.selection.contains(id); + self.selection.add(id); + self.selection_changed(scene); + !was_selected + } + + /// Toggle one object as used by Ctrl-click in either viewport or outliner. + pub fn toggle_selection(&mut self, scene: &S, id: Id) -> bool + where + S: EditableScene, + { + if !self.selection.contains(id) && scene.editor_transform(id).is_none() { + return false; + } + let remains_selected = self.selection.toggle(id); + self.selection_changed(scene); + remains_selected + } + + pub fn clear_selection(&mut self) { + self.selection.clear(); + self.selected = None; + self.hovered_gizmo = None; + self.transform_drag = None; + } + + fn selection_changed(&mut self, scene: &S) + where + S: EditableScene, + { + self.selected = self.selection.active(); + if let Some(pivot) = self.selection_pivot(scene) { + // Selection changes the orbit pivot without moving the camera; + // `F` additionally frames the complete selection. + self.navigation.set_orbit_target(&self.camera, pivot); + } + self.hovered_gizmo = None; + self.transform_drag = None; + } + + pub fn focus_selection(&mut self, scene: &S) -> bool + where + S: EditableScene, + { + let Some(target) = self.selection_focus_target(scene) else { + return false; + }; + self.camera.focus(target, 1.35); + self.navigation + .set_orbit_target(&self.camera, target.center); + true + } + + pub fn delete_selection(&mut self, scene: &mut S) -> bool + where + S: EditableScene, + { + if self.selection.is_empty() { + return false; + } + let ids = self.selection.ids().to_vec(); + let mut removed_any = false; + let mut retained = Vec::new(); + for id in ids { + if scene.remove_editor_object(id) { + removed_any = true; + } else { + retained.push(id); + } + } + if retained.is_empty() { + self.clear_selection(); + } else { + let active = retained.last().copied(); + self.set_selection_many(scene, retained, active); + } + removed_any + } + + pub fn duplicate_selection(&mut self, scene: &mut S) -> Option + where + S: EditableScene, + { + let active_original = self.selection.active()?; + let originals = self.selection.ids().to_vec(); + let mut duplicates = Vec::with_capacity(originals.len()); + let mut active_duplicate = None; + for original in originals { + let Some(duplicate) = scene.duplicate_editor_object(original) else { + continue; + }; + if original == active_original { + active_duplicate = Some(duplicate); + } + duplicates.push(duplicate); + } + let active_duplicate = active_duplicate.or_else(|| duplicates.last().copied())?; + self.set_selection_many(scene, duplicates, Some(active_duplicate)); + Some(active_duplicate) + } + + pub fn apply_command(&mut self, scene: &mut S, command: EditorCommand) -> bool + where + S: EditableScene, + { + match command { + EditorCommand::FocusSelection => self.focus_selection(scene), + EditorCommand::DeleteSelection => self.delete_selection(scene), + EditorCommand::DuplicateSelection => self.duplicate_selection(scene).is_some(), + EditorCommand::SetTransformMode(mode) => { + self.transform_mode = mode; + true + } + EditorCommand::ToggleTransformSpace => { + self.transform_space = match self.transform_space { + TransformSpace::World => TransformSpace::Local, + TransformSpace::Local => TransformSpace::World, + }; + true + } + } + } + + /// W/E/R switch gizmos outside navigation; F focuses; Delete and Ctrl+D + /// perform the usual object commands. + pub fn handle_shortcuts(&mut self, scene: &mut S, input: &InputFrame) -> bool + where + S: EditableScene, + { + use super::EditorKey; + + let mut changed = false; + if input.key_pressed(EditorKey::Delete) { + changed |= self.delete_selection(scene); + } + if input.modifiers.control && input.key_pressed(EditorKey::D) { + changed |= self.duplicate_selection(scene).is_some(); + } + if input.key_pressed(EditorKey::F) { + changed |= self.focus_selection(scene); + } + if !input.is_navigating() && self.transform_drag.is_none() { + let mode = if input.key_pressed(EditorKey::W) { + Some(TransformMode::Translate) + } else if input.key_pressed(EditorKey::E) { + Some(TransformMode::Rotate) + } else if input.key_pressed(EditorKey::R) { + Some(TransformMode::Scale) + } else { + None + }; + if let Some(mode) = mode { + changed |= self.transform_mode != mode; + self.transform_mode = mode; + } + } + changed + } + + /// Primary pivot used by the gizmo. For multi-selection this is the active + /// object's origin, so rotating/scaling a group never acts on each object in + /// isolation. + pub fn selection_pivot(&self, scene: &S) -> Option + where + S: EditableScene, + { + scene + .editor_transform(self.selection.active()?) + .map(|transform| transform.translation) + } + + fn selection_focus_target(&self, scene: &S) -> Option + where + S: EditableScene, + { + let targets = self + .selection + .ids() + .iter() + .filter_map(|id| scene.focus_target(*id)) + .collect::>(); + let first = *targets.first()?; + let mut minimum = first.center - Vec3::splat(first.radius); + let mut maximum = first.center + Vec3::splat(first.radius); + for target in &targets[1..] { + minimum = minimum.min(target.center - Vec3::splat(target.radius)); + maximum = maximum.max(target.center + Vec3::splat(target.radius)); + } + let center = (minimum + maximum) * 0.5; + let radius = targets + .iter() + .map(|target| center.distance(target.center) + target.radius) + .fold(0.001, f32::max); + Some(FocusTarget::new(center, radius)) + } + + pub fn gizmo_orientation(&self, scene: &S) -> Option + where + S: EditableScene, + { + match self.transform_space { + TransformSpace::World => Some(Quat::IDENTITY), + TransformSpace::Local => scene + .editor_transform(self.selection.active()?) + .map(|transform| transform.sanitized().rotation), + } + } + + /// World direction of an axis as it is currently drawn. Useful for a UI to + /// render the exact same local/world orientation that hit-testing uses. + pub fn gizmo_axis_direction(&self, scene: &S, axis: Axis) -> Option + where + S: EditableScene, + { + if axis == Axis::View { + return Some(self.camera.forward()); + } + let orientation = self.gizmo_orientation(scene)?; + Some(axis_from_orientation(axis, orientation, &self.camera)) + } + + pub fn gizmo_world_scale(&self, scene: &S, viewport_height: f32) -> Option + where + S: EditableScene, + { + gizmo::world_scale( + &self.camera, + self.selection_pivot(scene)?, + viewport_height, + self.gizmo_config.axis_length_pixels, + ) + } + + pub fn hit_test_gizmo(&self, scene: &S, pixel: Vec2, viewport_size: Vec2) -> Option + where + S: EditableScene, + { + if self.transform_drag.is_some() { + return None; + } + gizmo::hit_test( + &self.camera, + self.selection_pivot(scene)?, + self.gizmo_orientation(scene)?, + self.transform_mode, + pixel, + viewport_size, + self.gizmo_config, + ) + } + + pub fn update_gizmo_hover( + &mut self, + scene: &S, + pixel: Vec2, + viewport_size: Vec2, + ) -> Option + where + S: EditableScene, + { + if let Some(active) = self.gizmo_active() { + self.hovered_gizmo = Some(active); + } else { + self.hovered_gizmo = self + .hit_test_gizmo(scene, pixel, viewport_size) + .map(|hit| hit.handle); + } + self.hovered_gizmo + } + + pub fn clear_gizmo_hover(&mut self) { + if self.transform_drag.is_none() { + self.hovered_gizmo = None; + } + } + + pub fn gizmo_hovered(&self) -> Option { + self.hovered_gizmo + } + + pub fn gizmo_active(&self) -> Option { + self.transform_drag.as_ref().map(|drag| drag.handle) + } + + /// Hit-test and capture a real gizmo handle at the pointer position. + pub fn begin_gizmo_drag( + &mut self, + scene: &S, + pixel: Vec2, + viewport_size: Vec2, + ) -> Option + where + S: EditableScene, + { + let handle = self.hit_test_gizmo(scene, pixel, viewport_size)?.handle; + self.begin_gizmo_handle_drag_at(scene, handle, Some((pixel, viewport_size))) + .then_some(handle) + } + + /// Capture a known handle. Primarily useful for custom frontends and tests; + /// normal viewport code should call `begin_gizmo_drag` so capture follows + /// screen-space hit-testing. + pub fn begin_gizmo_handle_drag(&mut self, scene: &S, handle: GizmoHandle) -> bool + where + S: EditableScene, + { + self.begin_gizmo_handle_drag_at(scene, handle, None) + } + + fn begin_gizmo_handle_drag_at( + &mut self, + scene: &S, + handle: GizmoHandle, + pointer_context: Option<(Vec2, Vec2)>, + ) -> bool + where + S: EditableScene, + { + if handle.mode() != self.transform_mode || self.transform_drag.is_some() { + return false; + } + let Some(active_id) = self.selection.active() else { + return false; + }; + let Some(active_transform) = scene.editor_transform(active_id) else { + return false; + }; + let objects = self + .selection + .ids() + .iter() + .filter_map(|id| { + scene + .editor_transform(*id) + .map(|transform| (*id, transform)) + }) + .collect::>(); + if objects.is_empty() { + return false; + } + let pivot = active_transform.translation; + let orientation = match self.transform_space { + TransformSpace::World => Quat::IDENTITY, + TransformSpace::Local => active_transform.sanitized().rotation, + }; + let camera_distance = (pivot - self.camera.position) + .dot(self.camera.forward()) + .abs() + .max(0.01); + let geometric_constraint = pointer_context.and_then(|(pointer, viewport_size)| { + build_geometric_constraint( + &self.camera, + pivot, + orientation, + handle, + pointer, + viewport_size, + self.gizmo_config, + ) + }); + if pointer_context.is_some() && geometric_constraint.is_none() { + // A world-space constraint is undefined when, for example, an axis + // points almost exactly into the camera. Capturing such a handle + // would create an arbitrarily large jump, so leave it inactive. + return false; + } + let pointer_start = pointer_context.map(|(pointer, _)| pointer); + self.transform_drag = Some(TransformDrag { + objects, + mode: self.transform_mode, + handle, + pivot, + orientation, + accumulated_pixels: Vec2::ZERO, + camera_distance, + pointer_start, + pointer_current: pointer_start, + geometric_constraint, + }); + self.hovered_gizmo = Some(handle); + true + } + + /// Compatibility entry point for frontends that already resolve an axis. + /// New code should capture a concrete `GizmoHandle` instead. + pub fn begin_transform_drag(&mut self, scene: &S, axis: Axis) -> bool + where + S: EditableScene, + { + let handle = match self.transform_mode { + TransformMode::Translate => GizmoHandle::TranslateAxis(axis), + TransformMode::Rotate => GizmoHandle::RotateAxis(axis), + TransformMode::Scale if axis == Axis::View => GizmoHandle::ScaleUniform, + TransformMode::Scale => GizmoHandle::ScaleAxis(axis), + }; + self.begin_gizmo_handle_drag(scene, handle) + } + + /// Update a captured gizmo from an absolute top-left-origin viewport pixel. + pub fn update_gizmo_drag(&mut self, scene: &mut S, pixel: Vec2, viewport_size: Vec2) -> bool + where + S: EditableScene, + { + if !pixel.is_finite() + || !viewport_size.is_finite() + || viewport_size.x <= 0.0 + || viewport_size.y <= 0.0 + { + return false; + } + let Some(drag) = self.transform_drag.as_mut() else { + return false; + }; + let Some(start) = drag.pointer_start else { + drag.pointer_start = Some(pixel); + drag.pointer_current = Some(pixel); + return false; + }; + drag.pointer_current = Some(pixel); + drag.accumulated_pixels = pixel - start; + let snapshot = drag.clone(); + if snapshot.geometric_constraint.is_some() { + let Some(ray) = self.camera.viewport_ray(pixel, viewport_size) else { + return false; + }; + self.apply_geometric_transform_drag(scene, &snapshot, ray) + } else { + self.apply_legacy_transform_drag(scene, &snapshot, viewport_size.y, Some(viewport_size)) + } + } + + /// Delta-based compatibility update. Group/pivot behavior is identical, but + /// absolute `update_gizmo_drag` gives rotation rings the best feel. + pub fn update_transform_drag( + &mut self, + scene: &mut S, + delta_pixels: Vec2, + viewport_height: f32, + ) -> bool + where + S: EditableScene, + { + if !delta_pixels.is_finite() { + return false; + } + let Some(drag) = self.transform_drag.as_mut() else { + return false; + }; + drag.accumulated_pixels += delta_pixels; + if let Some(current) = &mut drag.pointer_current { + *current += delta_pixels; + } + let snapshot = drag.clone(); + self.apply_legacy_transform_drag(scene, &snapshot, viewport_height, None) + } + + fn apply_geometric_transform_drag( + &self, + scene: &mut S, + drag: &TransformDrag, + ray: ViewportRay, + ) -> bool + where + S: EditableScene, + { + let Some(constraint) = drag.geometric_constraint else { + return false; + }; + + match constraint { + GeometricConstraint::TranslateAxis(line) => { + let Some(parameter) = closest_axis_parameter(ray, line.origin, line.direction) + else { + return false; + }; + let translation = line.direction * (parameter - line.start_parameter); + self.apply_translation(scene, drag, translation) + } + GeometricConstraint::TranslatePlane { + normal, + start_point, + } => { + let Some(point) = ray_plane_intersection(ray, drag.pivot, normal) else { + return false; + }; + let delta = point - start_point; + let translation = delta - normal * delta.dot(normal); + self.apply_translation(scene, drag, translation) + } + GeometricConstraint::RotateRing { axis, start_vector } => { + let Some(point) = ray_plane_intersection(ray, drag.pivot, axis) else { + return false; + }; + let current_vector = (point - drag.pivot).normalize_or_zero(); + if current_vector.length_squared() <= f32::EPSILON { + return false; + } + // Positive motion follows the right-hand rule in world space. + // Unlike the previous screen-space perp-dot this is unaffected + // by the viewport's downward-pointing pixel Y axis. + let angle = axis + .dot(start_vector.cross(current_vector)) + .atan2(start_vector.dot(current_vector)); + self.apply_rotation(scene, drag, axis, angle) + } + GeometricConstraint::ScaleAxis { + line, + reference_length, + } => { + let Some(parameter) = closest_axis_parameter(ray, line.origin, line.direction) + else { + return false; + }; + let factor = scale_factor(parameter - line.start_parameter, reference_length); + let mut factors = Vec3::ONE; + match drag.handle { + GizmoHandle::ScaleAxis(Axis::X) => factors.x = factor, + GizmoHandle::ScaleAxis(Axis::Y) => factors.y = factor, + GizmoHandle::ScaleAxis(Axis::Z) => factors.z = factor, + GizmoHandle::ScaleAxis(Axis::View) => factors = Vec3::splat(factor), + _ => return false, + } + self.apply_scale(scene, drag, factors) + } + GeometricConstraint::ScaleUniform { + plane_normal, + start_point, + direction, + reference_length, + } => { + let Some(point) = ray_plane_intersection(ray, drag.pivot, plane_normal) else { + return false; + }; + let distance = (point - start_point).dot(direction); + self.apply_scale( + scene, + drag, + Vec3::splat(scale_factor(distance, reference_length)), + ) + } + } + } + + fn apply_translation( + &self, + scene: &mut S, + drag: &TransformDrag, + translation: Vec3, + ) -> bool + where + S: EditableScene, + { + let mut changed = false; + for (id, initial) in &drag.objects { + let mut transform = *initial; + transform.translation += translation; + changed |= scene.set_editor_transform(*id, transform.sanitized()); + } + changed + } + + fn apply_rotation( + &self, + scene: &mut S, + drag: &TransformDrag, + world_axis: Vec3, + angle: f32, + ) -> bool + where + S: EditableScene, + { + let rotation_delta = Quat::from_axis_angle(world_axis, angle); + let mut changed = false; + for (id, initial) in &drag.objects { + let mut transform = *initial; + transform.translation = + drag.pivot + rotation_delta * (initial.translation - drag.pivot); + transform.rotation = rotation_delta * initial.rotation; + changed |= scene.set_editor_transform(*id, transform.sanitized()); + } + changed + } + + fn apply_scale(&self, scene: &mut S, drag: &TransformDrag, factors: Vec3) -> bool + where + S: EditableScene, + { + let inverse_orientation = drag.orientation.conjugate(); + let mut changed = false; + for (id, initial) in &drag.objects { + let mut transform = *initial; + let oriented_offset = inverse_orientation * (initial.translation - drag.pivot); + transform.translation = drag.pivot + drag.orientation * (oriented_offset * factors); + transform.scale = initial.scale * factors; + changed |= scene.set_editor_transform(*id, transform.sanitized()); + } + changed + } + + /// Compatibility path for integrations that only provide accumulated pixel + /// deltas. The application viewport uses `apply_geometric_transform_drag`. + fn apply_legacy_transform_drag( + &self, + scene: &mut S, + drag: &TransformDrag, + viewport_height: f32, + viewport_size: Option, + ) -> bool + where + S: EditableScene, + { + let units_per_pixel = + world_units_per_pixel(&self.camera, drag.camera_distance, viewport_height); + let axes = gizmo::basis(drag.orientation); + let mut changed = false; + + match drag.mode { + TransformMode::Translate => { + let translation = match drag.handle { + GizmoHandle::TranslateAxis(Axis::View) => { + self.camera.right() * (drag.accumulated_pixels.x * units_per_pixel) + + self.camera.up() * (-drag.accumulated_pixels.y * units_per_pixel) + } + GizmoHandle::TranslateAxis(axis) => { + let axis = axis_from_basis(axis, axes, &self.camera); + let pixels = + projected_axis_delta(axis, &self.camera, drag.accumulated_pixels); + axis * pixels * units_per_pixel + } + GizmoHandle::TranslatePlane(plane) => plane_translation_delta( + plane, + axes, + &self.camera, + drag.accumulated_pixels, + units_per_pixel, + ), + _ => return false, + }; + for (id, initial) in &drag.objects { + let mut transform = *initial; + transform.translation += translation; + changed |= scene.set_editor_transform(*id, transform.sanitized()); + } + } + TransformMode::Rotate => { + let GizmoHandle::RotateAxis(axis) = drag.handle else { + return false; + }; + let world_axis = axis_from_basis(axis, axes, &self.camera); + let angle = rotation_drag_angle(drag, world_axis, &self.camera, viewport_size); + changed |= self.apply_rotation(scene, drag, world_axis, angle); + } + TransformMode::Scale => { + let (pixels, mut factors) = match drag.handle { + GizmoHandle::ScaleUniform => ( + drag.accumulated_pixels.x - drag.accumulated_pixels.y, + Vec3::ONE, + ), + GizmoHandle::ScaleAxis(axis) => { + let direction = axis_from_basis(axis, axes, &self.camera); + ( + projected_axis_delta(direction, &self.camera, drag.accumulated_pixels), + Vec3::ONE, + ) + } + _ => return false, + }; + let factor = (pixels * 0.01).exp().clamp(0.001, 1_000.0); + match drag.handle { + GizmoHandle::ScaleUniform => factors = Vec3::splat(factor), + GizmoHandle::ScaleAxis(Axis::X) => factors.x = factor, + GizmoHandle::ScaleAxis(Axis::Y) => factors.y = factor, + GizmoHandle::ScaleAxis(Axis::Z) => factors.z = factor, + GizmoHandle::ScaleAxis(Axis::View) => factors = Vec3::splat(factor), + _ => {} + } + changed |= self.apply_scale(scene, drag, factors); + } + } + changed + } + + pub fn end_gizmo_drag(&mut self) -> bool { + let Some(drag) = self.transform_drag.take() else { + return false; + }; + self.hovered_gizmo = Some(drag.handle); + true + } + + pub fn end_transform_drag(&mut self) -> bool { + self.end_gizmo_drag() + } + + pub fn cancel_gizmo_drag(&mut self, scene: &mut S) -> bool + where + S: EditableScene, + { + let Some(drag) = self.transform_drag.take() else { + return false; + }; + let mut restored = false; + for (id, initial) in drag.objects { + restored |= scene.set_editor_transform(id, initial); + } + self.hovered_gizmo = Some(drag.handle); + restored + } + + pub fn cancel_transform_drag(&mut self, scene: &mut S) -> bool + where + S: EditableScene, + { + self.cancel_gizmo_drag(scene) + } + + pub fn is_transform_dragging(&self) -> bool { + self.transform_drag.is_some() + } +} + +pub fn raymarch_scene( + scene: &S, + ray: ViewportRay, + settings: RaymarchSettings, +) -> Option> +where + S: EditableScene, +{ + if !ray.origin.is_finite() || !ray.direction.is_finite() { + return None; + } + let direction = ray.direction.normalize_or_zero(); + if direction.length_squared() == 0.0 { + return None; + } + + let settings = settings.sanitized(); + let mut distance_along_ray = 0.0; + for step in 0..settings.max_steps { + let position = ray.origin + direction * distance_along_ray; + let sample = scene.sample_for_pick(position); + if !sample.distance.is_finite() { + return None; + } + if sample.distance <= settings.hit_epsilon { + let object_id = sample.object_id?; + return Some(PickHit { + object_id, + position, + normal: estimate_normal(scene, position, settings.normal_epsilon), + distance: distance_along_ray, + steps: step + 1, + }); + } + + // A small safety factor is helpful for composed/approximated SDFs. + distance_along_ray += (sample.distance * 0.9).max(settings.min_step); + if distance_along_ray > settings.max_distance { + return None; + } + } + None +} + +fn estimate_normal(scene: &S, point: Vec3, epsilon: f32) -> Vec3 +where + S: EditableScene, +{ + let x = scene.sample_for_pick(point + Vec3::X * epsilon).distance + - scene.sample_for_pick(point - Vec3::X * epsilon).distance; + let y = scene.sample_for_pick(point + Vec3::Y * epsilon).distance + - scene.sample_for_pick(point - Vec3::Y * epsilon).distance; + let z = scene.sample_for_pick(point + Vec3::Z * epsilon).distance + - scene.sample_for_pick(point - Vec3::Z * epsilon).distance; + Vec3::new(x, y, z).normalize_or_zero() +} + +fn build_geometric_constraint( + camera: &Camera, + pivot: Vec3, + orientation: Quat, + handle: GizmoHandle, + pointer: Vec2, + viewport_size: Vec2, + config: GizmoConfig, +) -> Option { + let ray = camera.viewport_ray(pointer, viewport_size)?; + let axes = gizmo::basis(orientation); + let world_scale = + gizmo::world_scale(camera, pivot, viewport_size.y, config.axis_length_pixels)?; + + match handle { + GizmoHandle::TranslateAxis(Axis::View) => { + let normal = camera.forward(); + let start_point = ray_plane_intersection(ray, pivot, normal)?; + Some(GeometricConstraint::TranslatePlane { + normal, + start_point, + }) + } + GizmoHandle::TranslateAxis(axis) => { + let direction = axis_from_basis(axis, axes, camera).normalize_or_zero(); + let start_parameter = closest_axis_parameter(ray, pivot, direction)?; + Some(GeometricConstraint::TranslateAxis(AxisLineConstraint { + origin: pivot, + direction, + start_parameter, + })) + } + GizmoHandle::TranslatePlane(plane) => { + let (first, second) = plane.indices(); + let normal = axes[first].cross(axes[second]).normalize_or_zero(); + let start_point = ray_plane_intersection(ray, pivot, normal)?; + Some(GeometricConstraint::TranslatePlane { + normal, + start_point, + }) + } + GizmoHandle::RotateAxis(axis) => { + let axis = axis_from_basis(axis, axes, camera).normalize_or_zero(); + let point = ray_plane_intersection(ray, pivot, axis)?; + let start_vector = (point - pivot).normalize_or_zero(); + (start_vector.length_squared() > f32::EPSILON) + .then_some(GeometricConstraint::RotateRing { axis, start_vector }) + } + GizmoHandle::ScaleAxis(Axis::View) | GizmoHandle::ScaleUniform => { + let plane_normal = camera.forward(); + let start_point = ray_plane_intersection(ray, pivot, plane_normal)?; + let direction = (camera.right() + camera.up()).normalize_or_zero(); + Some(GeometricConstraint::ScaleUniform { + plane_normal, + start_point, + direction, + reference_length: world_scale, + }) + } + GizmoHandle::ScaleAxis(axis) => { + let direction = axis_from_basis(axis, axes, camera).normalize_or_zero(); + let start_parameter = closest_axis_parameter(ray, pivot, direction)?; + Some(GeometricConstraint::ScaleAxis { + line: AxisLineConstraint { + origin: pivot, + direction, + start_parameter, + }, + reference_length: world_scale, + }) + } + } +} + +/// Parameter on an infinite world axis at the closest approach to a viewport +/// ray. A nearly parallel pair has no stable solution and is deliberately +/// rejected instead of amplifying a few pixels into a huge world-space jump. +fn closest_axis_parameter(ray: ViewportRay, axis_origin: Vec3, axis: Vec3) -> Option { + const MIN_SIN_SQUARED: f32 = 1.0e-4; + + let axis = axis.normalize_or_zero(); + let ray_direction = ray.direction.normalize_or_zero(); + if axis.length_squared() <= f32::EPSILON || ray_direction.length_squared() <= f32::EPSILON { + return None; + } + let between_origins = axis_origin - ray.origin; + let direction_dot = axis.dot(ray_direction); + let denominator = 1.0 - direction_dot * direction_dot; + if !denominator.is_finite() || denominator <= MIN_SIN_SQUARED { + return None; + } + let parameter = (direction_dot * ray_direction.dot(between_origins) + - axis.dot(between_origins)) + / denominator; + parameter.is_finite().then_some(parameter) +} + +fn ray_plane_intersection(ray: ViewportRay, plane_point: Vec3, normal: Vec3) -> Option { + const MIN_DENOMINATOR: f32 = 1.0e-5; + + let normal = normal.normalize_or_zero(); + let direction = ray.direction.normalize_or_zero(); + let denominator = direction.dot(normal); + if denominator.abs() <= MIN_DENOMINATOR { + return None; + } + let distance = (plane_point - ray.origin).dot(normal) / denominator; + if !distance.is_finite() || distance < 0.0 { + return None; + } + let point = ray.origin + direction * distance; + point.is_finite().then_some(point) +} + +/// Moving by one visible gizmo-axis length doubles the affected scale. The +/// exponential keeps the scale positive and behaves symmetrically inward and +/// outward while still being based entirely on world distance. +fn scale_factor(distance: f32, reference_length: f32) -> f32 { + if !distance.is_finite() || !reference_length.is_finite() || reference_length <= 0.0 { + return 1.0; + } + 2.0_f32 + .powf(distance / reference_length) + .clamp(0.001, 1_000.0) +} + +fn axis_from_orientation(axis: Axis, orientation: Quat, camera: &Camera) -> Vec3 { + axis_from_basis(axis, gizmo::basis(orientation), camera) +} + +fn axis_from_basis(axis: Axis, basis: [Vec3; 3], camera: &Camera) -> Vec3 { + match axis { + Axis::X => basis[0], + Axis::Y => basis[1], + Axis::Z => basis[2], + Axis::View => camera.forward(), + } +} + +fn projected_axis_delta(axis: Vec3, camera: &Camera, delta: Vec2) -> f32 { + let projected = Vec2::new(axis.dot(camera.right()), -axis.dot(camera.up())); + if projected.length_squared() < 1.0e-6 { + -delta.y + } else { + delta.dot(projected.normalize()) + } +} + +fn projected_rotation_delta(axis: Vec3, camera: &Camera, delta: Vec2) -> f32 { + let projected = Vec2::new(axis.dot(camera.right()), -axis.dot(camera.up())); + if projected.length_squared() < 1.0e-6 { + delta.x - delta.y + } else { + let tangent = Vec2::new(-projected.y, projected.x).normalize(); + delta.dot(tangent) + } +} + +fn plane_translation_delta( + plane: Plane, + basis: [Vec3; 3], + camera: &Camera, + delta: Vec2, + units_per_pixel: f32, +) -> Vec3 { + let (a_index, b_index) = plane.indices(); + let a = basis[a_index]; + let b = basis[b_index]; + let screen_a = Vec2::new(a.dot(camera.right()), -a.dot(camera.up())); + let screen_b = Vec2::new(b.dot(camera.right()), -b.dot(camera.up())); + let determinant = screen_a.perp_dot(screen_b); + if determinant.abs() < 1.0e-4 { + let (axis, screen) = if screen_a.length_squared() >= screen_b.length_squared() { + (a, screen_a) + } else { + (b, screen_b) + }; + if screen.length_squared() < 1.0e-6 { + return Vec3::ZERO; + } + return axis * delta.dot(screen.normalize()) * units_per_pixel; + } + let coefficient_a = delta.perp_dot(screen_b) / determinant; + let coefficient_b = screen_a.perp_dot(delta) / determinant; + (a * coefficient_a + b * coefficient_b) * units_per_pixel +} + +fn rotation_drag_angle( + drag: &TransformDrag, + world_axis: Vec3, + camera: &Camera, + viewport_size: Option, +) -> f32 { + if world_axis.dot(camera.forward()).abs() > 0.35 + && let (Some(start), Some(current), Some(viewport_size)) = + (drag.pointer_start, drag.pointer_current, viewport_size) + && let Some(pivot) = gizmo::project_world(camera, drag.pivot, viewport_size) + { + let from = start - pivot; + let to = current - pivot; + if from.length_squared() > 16.0 && to.length_squared() > 16.0 { + return from.perp_dot(to).atan2(from.dot(to)); + } + } + + projected_rotation_delta(world_axis, camera, drag.accumulated_pixels) * 0.01 +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + + use super::*; + + #[derive(Default)] + struct SphereScene; + + impl EditableScene for SphereScene { + type Id = u32; + + fn sample_for_pick(&self, point: Vec3) -> PickSample { + PickSample { + distance: point.length() - 1.0, + object_id: Some(7), + } + } + + fn editor_transform(&self, _id: Self::Id) -> Option { + None + } + + fn set_editor_transform(&mut self, _id: Self::Id, _transform: EditorTransform) -> bool { + false + } + + fn remove_editor_object(&mut self, _id: Self::Id) -> bool { + false + } + + fn duplicate_editor_object(&mut self, _id: Self::Id) -> Option { + None + } + + fn focus_target(&self, _id: Self::Id) -> Option { + None + } + } + + #[derive(Default)] + struct ObjectScene { + objects: BTreeMap, + } + + impl ObjectScene { + fn with_objects(objects: &[(u32, Vec3)]) -> Self { + Self { + objects: objects + .iter() + .map(|(id, translation)| { + ( + *id, + EditorTransform { + translation: *translation, + ..EditorTransform::default() + }, + ) + }) + .collect(), + } + } + + fn transform(&self, id: u32) -> EditorTransform { + self.objects[&id] + } + } + + impl EditableScene for ObjectScene { + type Id = u32; + + fn sample_for_pick(&self, point: Vec3) -> PickSample { + self.objects + .iter() + .map(|(id, transform)| PickSample { + distance: point.distance(transform.translation) - 0.5, + object_id: Some(*id), + }) + .min_by(|left, right| left.distance.total_cmp(&right.distance)) + .unwrap_or(PickSample { + distance: f32::INFINITY, + object_id: None, + }) + } + + fn editor_transform(&self, id: Self::Id) -> Option { + self.objects.get(&id).copied() + } + + fn set_editor_transform(&mut self, id: Self::Id, transform: EditorTransform) -> bool { + let Some(current) = self.objects.get_mut(&id) else { + return false; + }; + *current = transform; + true + } + + fn remove_editor_object(&mut self, id: Self::Id) -> bool { + self.objects.remove(&id).is_some() + } + + fn duplicate_editor_object(&mut self, id: Self::Id) -> Option { + let transform = self.objects.get(&id).copied()?; + let duplicate = self.objects.keys().next_back().copied().unwrap_or(0) + 1; + self.objects.insert(duplicate, transform); + Some(duplicate) + } + + fn focus_target(&self, id: Self::Id) -> Option { + Some(FocusTarget::new(self.objects.get(&id)?.translation, 0.5)) + } + } + + fn test_editor() -> EditorState { + EditorState { + camera: Camera::new(Vec3::new(0.0, -5.0, 0.0), 0.0, 0.0), + ..EditorState::default() + } + } + + #[test] + fn cpu_raymarch_picks_sdf_and_estimates_normal() { + let hit = raymarch_scene( + &SphereScene, + ViewportRay { + origin: Vec3::new(0.0, 0.0, 3.0), + direction: Vec3::NEG_Z, + }, + RaymarchSettings::default(), + ) + .unwrap(); + assert_eq!(hit.object_id, 7); + assert!((hit.distance - 2.0).abs() < 0.01); + assert!(hit.normal.abs_diff_eq(Vec3::Z, 0.01)); + } + + #[test] + fn ordered_selection_supports_replace_add_toggle_and_clear() { + let scene = ObjectScene::with_objects(&[(1, Vec3::ZERO), (2, Vec3::X), (3, Vec3::Y)]); + let mut editor = test_editor(); + + editor.set_selection_many(&scene, [1, 2, 2], Some(1)); + assert_eq!(editor.selection.ids(), &[1, 2]); + assert_eq!(editor.selection.active(), Some(1)); + assert!(editor.selection.contains(2)); + + assert!(!editor.toggle_selection(&scene, 1)); + assert_eq!(editor.selection.ids(), &[2]); + assert_eq!(editor.selection.active(), Some(2)); + + assert!(editor.add_to_selection(&scene, 3)); + assert_eq!(editor.selection.ids(), &[2, 3]); + assert_eq!(editor.selection.active(), Some(3)); + editor.clear_selection(); + assert!(editor.selection.is_empty()); + assert_eq!(editor.selection.active(), None); + } + + #[test] + fn viewport_toggle_mode_adds_and_removes_the_hit() { + let scene = ObjectScene::with_objects(&[(1, Vec3::ZERO)]); + let mut editor = test_editor(); + let viewport = Vec2::new(800.0, 600.0); + let center = viewport * 0.5; + + let hit = editor + .select_at_with_mode(&scene, center, viewport, SelectionMode::Toggle) + .unwrap(); + assert_eq!(hit.object_id, 1); + assert_eq!(editor.selection.ids(), &[1]); + + editor.select_at_with_mode(&scene, center, viewport, SelectionMode::Toggle); + assert!(editor.selection.is_empty()); + } + + #[test] + fn translation_drag_moves_every_selected_object_by_the_same_delta() { + let mut scene = + ObjectScene::with_objects(&[(1, Vec3::ZERO), (2, Vec3::new(2.0, 1.0, 0.0))]); + let mut editor = test_editor(); + editor.set_selection_many(&scene, [1, 2], Some(1)); + assert!(editor.begin_gizmo_handle_drag(&scene, GizmoHandle::TranslateAxis(Axis::X))); + assert!(editor.update_transform_drag(&mut scene, Vec2::new(60.0, 0.0), 600.0)); + + let first = scene.transform(1).translation; + let second = scene.transform(2).translation; + assert!(first.x > 0.0); + assert!((second - first).abs_diff_eq(Vec3::new(2.0, 1.0, 0.0), 1.0e-5)); + } + + #[test] + fn rotation_drag_uses_active_pivot_for_the_whole_group() { + let mut scene = ObjectScene::with_objects(&[(1, Vec3::ZERO), (2, Vec3::X * 2.0)]); + let mut editor = test_editor(); + editor.transform_mode = TransformMode::Rotate; + editor.set_selection_many(&scene, [1, 2], Some(1)); + assert!(editor.begin_gizmo_handle_drag(&scene, GizmoHandle::RotateAxis(Axis::Z))); + assert!(editor.update_transform_drag(&mut scene, Vec2::new(100.0, 0.0), 600.0)); + + let expected_rotation = Quat::from_rotation_z(1.0); + assert!( + scene + .transform(1) + .rotation + .abs_diff_eq(expected_rotation, 1.0e-5) + ); + assert!( + scene + .transform(2) + .translation + .abs_diff_eq(expected_rotation * (Vec3::X * 2.0), 1.0e-5) + ); + } + + #[test] + fn axis_scale_uses_active_pivot_for_group_offsets() { + let mut scene = ObjectScene::with_objects(&[(1, Vec3::ZERO), (2, Vec3::X * 2.0)]); + let mut editor = test_editor(); + editor.transform_mode = TransformMode::Scale; + editor.set_selection_many(&scene, [1, 2], Some(1)); + assert!(editor.begin_gizmo_handle_drag(&scene, GizmoHandle::ScaleAxis(Axis::X))); + let double_pixels = 2.0_f32.ln() / 0.01; + assert!(editor.update_transform_drag(&mut scene, Vec2::new(double_pixels, 0.0), 600.0)); + + assert!( + scene + .transform(2) + .translation + .abs_diff_eq(Vec3::X * 4.0, 1.0e-4) + ); + assert!((scene.transform(1).scale.x - 2.0).abs() < 1.0e-4); + assert!((scene.transform(2).scale.x - 2.0).abs() < 1.0e-4); + } + + #[test] + fn hover_capture_and_release_report_gizmo_handle_state() { + let mut scene = ObjectScene::with_objects(&[(1, Vec3::ZERO)]); + let mut editor = test_editor(); + editor.set_selection(&scene, Some(1)); + let viewport = Vec2::new(800.0, 600.0); + let pointer = Vec2::new(460.0, 300.0); + + assert_eq!( + editor.update_gizmo_hover(&scene, pointer, viewport), + Some(GizmoHandle::TranslateAxis(Axis::X)) + ); + assert_eq!( + editor.begin_gizmo_drag(&scene, pointer, viewport), + Some(GizmoHandle::TranslateAxis(Axis::X)) + ); + assert_eq!(editor.gizmo_active(), editor.gizmo_hovered()); + assert!(editor.update_gizmo_drag(&mut scene, pointer + Vec2::new(20.0, 0.0), viewport)); + assert!(editor.end_gizmo_drag()); + assert_eq!(editor.gizmo_active(), None); + } + + fn oblique_editor(pivot: Vec3) -> EditorState { + let mut editor = EditorState { + camera: Camera::new(Vec3::new(6.0, -8.0, 5.0), 0.0, 0.0), + ..EditorState::default() + }; + editor.camera.look_at(pivot); + editor + } + + fn screen(camera: &Camera, world: Vec3, viewport: Vec2) -> Vec2 { + gizmo::project_world(camera, world, viewport).expect("test point must be visible") + } + + #[test] + fn absolute_axis_drag_stays_on_world_line_with_oblique_camera() { + let pivot = Vec3::new(0.5, -0.25, 1.0); + let mut scene = ObjectScene::with_objects(&[(1, pivot)]); + let mut editor = oblique_editor(pivot); + editor.set_selection(&scene, Some(1)); + let viewport = Vec2::new(1280.0, 720.0); + let widget_scale = editor.gizmo_world_scale(&scene, viewport.y).unwrap(); + let press_world = pivot + Vec3::X * (widget_scale * 0.72); + let target_world = press_world + Vec3::X * 2.25; + let press = screen(&editor.camera, press_world, viewport); + let target = screen(&editor.camera, target_world, viewport); + + assert_eq!( + editor.begin_gizmo_drag(&scene, press, viewport), + Some(GizmoHandle::TranslateAxis(Axis::X)) + ); + assert!(editor.update_gizmo_drag(&mut scene, target, viewport)); + + let translation = scene.transform(1).translation - pivot; + assert!(translation.abs_diff_eq(Vec3::X * 2.25, 2.0e-4)); + assert!(translation.cross(Vec3::X).length() < 1.0e-5); + } + + #[test] + fn absolute_plane_drag_is_ray_plane_intersection_not_screen_delta() { + let pivot = Vec3::new(-0.5, 0.75, 1.25); + let mut scene = ObjectScene::with_objects(&[(1, pivot)]); + let mut editor = oblique_editor(pivot); + editor.set_selection(&scene, Some(1)); + let viewport = Vec2::new(1280.0, 720.0); + let widget_scale = editor.gizmo_world_scale(&scene, viewport.y).unwrap(); + let press_world = pivot + (Vec3::X + Vec3::Y) * (widget_scale * 0.3); + let world_delta = Vec3::new(1.2, -0.8, 0.0); + let press = screen(&editor.camera, press_world, viewport); + let target = screen(&editor.camera, press_world + world_delta, viewport); + + assert!(editor.begin_gizmo_handle_drag_at( + &scene, + GizmoHandle::TranslatePlane(Plane::XY), + Some((press, viewport)), + )); + assert!(editor.update_gizmo_drag(&mut scene, target, viewport)); + + assert!((scene.transform(1).translation - pivot).abs_diff_eq(world_delta, 2.0e-4)); + assert!((scene.transform(1).translation.z - pivot.z).abs() < 1.0e-5); + } + + #[test] + fn rotation_ring_uses_world_signed_angle_and_follows_visual_motion() { + let pivot = Vec3::new(0.0, 0.0, 1.0); + let mut scene = ObjectScene::with_objects(&[(1, pivot)]); + let mut editor = oblique_editor(pivot); + editor.transform_mode = TransformMode::Rotate; + editor.set_selection(&scene, Some(1)); + let viewport = Vec2::new(1280.0, 720.0); + let widget_scale = editor.gizmo_world_scale(&scene, viewport.y).unwrap(); + let radius = widget_scale * editor.gizmo_config.ring_radius_pixels + / editor.gizmo_config.axis_length_pixels; + let start_angle = 25.0_f32.to_radians(); + let end_angle = 70.0_f32.to_radians(); + let on_ring = |angle: f32| pivot + Vec3::new(angle.cos(), angle.sin(), 0.0) * radius; + let press = screen(&editor.camera, on_ring(start_angle), viewport); + let target = screen(&editor.camera, on_ring(end_angle), viewport); + + assert_eq!( + editor.begin_gizmo_drag(&scene, press, viewport), + Some(GizmoHandle::RotateAxis(Axis::Z)) + ); + assert!(editor.update_gizmo_drag(&mut scene, target, viewport)); + + let expected = Quat::from_rotation_z(end_angle - start_angle); + assert!(scene.transform(1).rotation.abs_diff_eq(expected, 2.0e-4)); + let rotated_x = scene.transform(1).rotation * Vec3::X; + assert!( + rotated_x.y > 0.0, + "positive ring drag must rotate +X toward +Y" + ); + } + + #[test] + fn axis_and_uniform_scale_are_derived_from_world_intersections() { + let pivot = Vec3::new(0.0, 0.0, 1.0); + let viewport = Vec2::new(1280.0, 720.0); + + let mut axis_scene = ObjectScene::with_objects(&[(1, pivot), (2, pivot + Vec3::X * 2.0)]); + let mut axis_editor = oblique_editor(pivot); + axis_editor.transform_mode = TransformMode::Scale; + axis_editor.set_selection_many(&axis_scene, [1, 2], Some(1)); + let widget_scale = axis_editor + .gizmo_world_scale(&axis_scene, viewport.y) + .unwrap(); + let press_world = pivot + Vec3::X * (widget_scale * 0.72); + let press = screen(&axis_editor.camera, press_world, viewport); + let target = screen( + &axis_editor.camera, + press_world + Vec3::X * widget_scale, + viewport, + ); + assert!(axis_editor.begin_gizmo_handle_drag_at( + &axis_scene, + GizmoHandle::ScaleAxis(Axis::X), + Some((press, viewport)), + )); + assert!(axis_editor.update_gizmo_drag(&mut axis_scene, target, viewport)); + assert!((axis_scene.transform(1).scale.x - 2.0).abs() < 2.0e-4); + assert!( + axis_scene + .transform(2) + .translation + .abs_diff_eq(pivot + Vec3::X * 4.0, 3.0e-4) + ); + + let mut uniform_scene = ObjectScene::with_objects(&[(1, pivot)]); + let mut uniform_editor = oblique_editor(pivot); + uniform_editor.transform_mode = TransformMode::Scale; + uniform_editor.set_selection(&uniform_scene, Some(1)); + let widget_scale = uniform_editor + .gizmo_world_scale(&uniform_scene, viewport.y) + .unwrap(); + let plane_direction = + (uniform_editor.camera.right() + uniform_editor.camera.up()).normalize(); + let press = screen(&uniform_editor.camera, pivot, viewport); + let target = screen( + &uniform_editor.camera, + pivot + plane_direction * widget_scale, + viewport, + ); + assert!(uniform_editor.begin_gizmo_handle_drag_at( + &uniform_scene, + GizmoHandle::ScaleUniform, + Some((press, viewport)), + )); + assert!(uniform_editor.update_gizmo_drag(&mut uniform_scene, target, viewport)); + assert!( + uniform_scene + .transform(1) + .scale + .abs_diff_eq(Vec3::splat(2.0), 2.0e-4) + ); + } + + #[test] + fn local_and_world_space_change_drawing_hit_test_and_drag_axis() { + let pivot = Vec3::new(0.0, 0.0, 1.0); + let mut scene = ObjectScene::with_objects(&[(1, pivot)]); + scene.objects.get_mut(&1).unwrap().rotation = + Quat::from_rotation_z(std::f32::consts::FRAC_PI_2); + let viewport = Vec2::new(1280.0, 720.0); + + let mut world_editor = oblique_editor(pivot); + world_editor.transform_space = TransformSpace::World; + world_editor.set_selection(&scene, Some(1)); + assert!( + world_editor + .gizmo_axis_direction(&scene, Axis::X) + .unwrap() + .abs_diff_eq(Vec3::X, 1.0e-5) + ); + let world_scale = world_editor.gizmo_world_scale(&scene, viewport.y).unwrap(); + let world_press = screen( + &world_editor.camera, + pivot + Vec3::X * (world_scale * 0.72), + viewport, + ); + assert_eq!( + world_editor + .hit_test_gizmo(&scene, world_press, viewport) + .map(|hit| hit.handle), + Some(GizmoHandle::TranslateAxis(Axis::X)) + ); + + let mut local_scene = scene; + let mut local_editor = oblique_editor(pivot); + local_editor.transform_space = TransformSpace::Local; + local_editor.set_selection(&local_scene, Some(1)); + assert!( + local_editor + .gizmo_axis_direction(&local_scene, Axis::X) + .unwrap() + .abs_diff_eq(Vec3::Y, 1.0e-5) + ); + let local_scale = local_editor + .gizmo_world_scale(&local_scene, viewport.y) + .unwrap(); + let local_press_world = pivot + Vec3::Y * (local_scale * 0.72); + let local_press = screen(&local_editor.camera, local_press_world, viewport); + assert_eq!( + local_editor + .hit_test_gizmo(&local_scene, local_press, viewport) + .map(|hit| hit.handle), + Some(GizmoHandle::TranslateAxis(Axis::X)) + ); + assert!( + local_editor + .begin_gizmo_drag(&local_scene, local_press, viewport) + .is_some() + ); + let target = screen( + &local_editor.camera, + local_press_world + Vec3::Y * 1.5, + viewport, + ); + assert!(local_editor.update_gizmo_drag(&mut local_scene, target, viewport)); + assert!((local_scene.transform(1).translation - pivot).abs_diff_eq(Vec3::Y * 1.5, 2.0e-4)); + } +} diff --git a/src/io/dialog.rs b/src/io/dialog.rs new file mode 100644 index 0000000..cf47722 --- /dev/null +++ b/src/io/dialog.rs @@ -0,0 +1,85 @@ +use std::path::{Path, PathBuf}; + +use rfd::FileDialog; +use winit::window::Window; + +const DEFAULT_FILE_NAME: &str = "map.json"; + +/// Show the platform-native Save dialog. The caller owns the returned path and +/// decides when to write, which keeps dialogs entirely out of unit tests. +pub fn choose_map_save_path(window: &Window, current: Option<&Path>) -> Option { + let mut dialog = map_dialog(window, "Сохранить карту"); + if let Some(directory) = starting_directory(current) { + dialog = dialog.set_directory(directory); + } + let suggested_name = current + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + .unwrap_or(DEFAULT_FILE_NAME); + dialog + .set_file_name(suggested_name) + .save_file() + .map(ensure_json_extension) +} + +/// Show the platform-native Open dialog for one versioned JSON map. +pub fn choose_map_open_path(window: &Window, current: Option<&Path>) -> Option { + let mut dialog = map_dialog(window, "Открыть карту"); + if let Some(directory) = starting_directory(current) { + dialog = dialog.set_directory(directory); + } + dialog.pick_file() +} + +fn map_dialog(window: &Window, title: &str) -> FileDialog { + FileDialog::new() + .set_parent(window) + .set_title(title) + .add_filter("Карта Ray Marching (JSON)", &["json"]) +} + +fn starting_directory(current: Option<&Path>) -> Option { + current + .and_then(|path| { + if path.is_dir() { + Some(path) + } else { + path.parent() + } + }) + .filter(|path| !path.as_os_str().is_empty()) + .map(Path::to_owned) + .or_else(|| std::env::current_dir().ok()) +} + +pub(crate) fn ensure_json_extension(mut path: PathBuf) -> PathBuf { + let is_json = path + .extension() + .and_then(|extension| extension.to_str()) + .is_some_and(|extension| extension.eq_ignore_ascii_case("json")); + if !is_json { + path.set_extension("json"); + } + path +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn save_path_gets_one_json_extension() { + assert_eq!( + ensure_json_extension(PathBuf::from("level")), + PathBuf::from("level.json") + ); + assert_eq!( + ensure_json_extension(PathBuf::from("level.JSON")), + PathBuf::from("level.JSON") + ); + assert_eq!( + ensure_json_extension(PathBuf::from("level.txt")), + PathBuf::from("level.json") + ); + } +} diff --git a/src/io/map.rs b/src/io/map.rs new file mode 100644 index 0000000..b8bf4ff --- /dev/null +++ b/src/io/map.rs @@ -0,0 +1,253 @@ +use std::{ + fs::{self, OpenOptions}, + io::Write, + path::{Path, PathBuf}, + sync::atomic::{AtomicU64, Ordering}, +}; + +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use crate::scene::{Scene, SceneValidationError}; + +pub const CURRENT_MAP_VERSION: u32 = 1; +pub const DEFAULT_MAP_PATH: &str = "map.json"; + +static TEMP_FILE_COUNTER: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct MapDocument { + pub version: u32, + pub scene: Scene, +} + +impl MapDocument { + pub fn new(scene: Scene) -> Self { + Self { + version: CURRENT_MAP_VERSION, + scene, + } + } +} + +#[derive(Debug, Error)] +pub enum MapIoError { + #[error("failed to access map file {path}: {source}")] + Io { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to serialize map JSON: {0}")] + Serialize(#[source] serde_json::Error), + #[error("failed to parse map JSON: {0}")] + Deserialize(#[source] serde_json::Error), + #[error("map version {found} is unsupported; this build supports version {supported}")] + UnsupportedVersion { found: u32, supported: u32 }, + #[error("map scene is invalid: {0}")] + InvalidScene(#[from] SceneValidationError), +} + +/// Serializes a map using the current versioned envelope. +pub fn serialize_map(scene: &Scene) -> Result { + let mut scene = scene.clone(); + scene.repair_allocator()?; + scene.validate()?; + serde_json::to_string_pretty(&MapDocument::new(scene)).map_err(MapIoError::Serialize) +} + +/// Parses and validates a map. Allocator metadata may be repaired, but stable +/// object IDs from the document are never changed. +pub fn deserialize_map(json: &str) -> Result { + let mut document: MapDocument = serde_json::from_str(json).map_err(MapIoError::Deserialize)?; + if document.version != CURRENT_MAP_VERSION { + return Err(MapIoError::UnsupportedVersion { + found: document.version, + supported: CURRENT_MAP_VERSION, + }); + } + document.scene.repair_allocator()?; + document.scene.validate()?; + Ok(document.scene) +} + +pub fn load_map(path: impl AsRef) -> Result { + let path = path.as_ref(); + let json = fs::read_to_string(path).map_err(|source| MapIoError::Io { + path: path.to_owned(), + source, + })?; + deserialize_map(&json) +} + +pub fn load_default_map() -> Result { + load_map(DEFAULT_MAP_PATH) +} + +/// Writes beside the destination, flushes the temporary file, then atomically +/// renames it over the map. A crash can therefore leave either the old or the +/// new complete JSON document, never a half-written destination. +pub fn save_map(path: impl AsRef, scene: &Scene) -> Result<(), MapIoError> { + let path = path.as_ref(); + let json = serialize_map(scene)?; + let parent = path + .parent() + .filter(|parent| !parent.as_os_str().is_empty()); + if let Some(parent) = parent { + fs::create_dir_all(parent).map_err(|source| MapIoError::Io { + path: parent.to_owned(), + source, + })?; + } + + let temporary = temporary_path(path); + let write_result = (|| { + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temporary) + .map_err(|source| MapIoError::Io { + path: temporary.clone(), + source, + })?; + file.write_all(json.as_bytes()) + .and_then(|()| file.write_all(b"\n")) + .and_then(|()| file.sync_all()) + .map_err(|source| MapIoError::Io { + path: temporary.clone(), + source, + })?; + drop(file); + fs::rename(&temporary, path).map_err(|source| MapIoError::Io { + path: path.to_owned(), + source, + }) + })(); + + if write_result.is_err() { + let _ = fs::remove_file(&temporary); + } + write_result +} + +pub fn save_default_map(scene: &Scene) -> Result<(), MapIoError> { + save_map(DEFAULT_MAP_PATH, scene) +} + +fn temporary_path(destination: &Path) -> PathBuf { + let parent = destination.parent().unwrap_or_else(|| Path::new(".")); + let name = destination + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("map.json"); + let counter = TEMP_FILE_COUNTER.fetch_add(1, Ordering::Relaxed); + parent.join(format!(".{name}.{}.{}.tmp", std::process::id(), counter)) +} + +#[cfg(test)] +mod tests { + use std::time::{SystemTime, UNIX_EPOCH}; + + use glam::{Quat, Vec3}; + + use super::*; + use crate::scene::{LightKind, Material, SdfPrimitive, Transform}; + + fn test_path(name: &str) -> PathBuf { + let nonce = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + std::env::temp_dir().join(format!( + "ray-marching-editor-{name}-{}-{nonce}.json", + std::process::id() + )) + } + + #[test] + fn json_round_trip_preserves_scene_ids_lights_and_settings() { + let mut scene = Scene::default(); + scene.name = "Round Trip".into(); + scene.clouds.coverage = 0.73; + scene.editor_camera.position = Vec3::new(9.0, 8.0, 7.0); + let id = scene.add_primitive_with( + "Hero Shape", + SdfPrimitive::RoundedBox { + half_extents: Vec3::new(1.0, 2.0, 3.0), + radius: 0.25, + }, + Transform { + translation: Vec3::new(4.0, 5.0, 6.0), + rotation: Quat::from_rotation_y(0.4), + scale: Vec3::new(1.0, 2.0, 1.0), + }, + Material { + albedo: Vec3::new(0.2, 0.5, 0.8), + roughness: 0.3, + metallic: 0.7, + ..Material::default() + }, + ); + let black_hole = scene.add_primitive(SdfPrimitive::BlackHole { + horizon_radius: 0.8, + influence_radius: 12.0, + lens_strength: 2.4, + spin: -0.65, + }); + let spot = scene.add_light(LightKind::Spot { + range: 40.0, + inner_angle_radians: 0.2, + outer_angle_radians: 0.4, + }); + + let json = serialize_map(&scene).unwrap(); + let restored = deserialize_map(&json).unwrap(); + assert_eq!(restored, scene); + assert!(restored.primitive(id).is_some()); + let Some(SdfPrimitive::BlackHole { + horizon_radius, + influence_radius, + lens_strength, + spin, + }) = restored + .primitive(black_hole) + .map(|primitive| primitive.shape) + else { + panic!("the black hole must survive map serialization"); + }; + assert_eq!( + [horizon_radius, influence_radius, lens_strength, spin], + [0.8, 12.0, 2.4, -0.65] + ); + assert!(restored.light(spot).is_some()); + + let mut restored = restored; + let new_id = restored.add_primitive(SdfPrimitive::Sphere { radius: 1.0 }); + assert!(new_id > id.max(black_hole).max(spot)); + } + + #[test] + fn atomic_save_replaces_the_single_map() { + let path = test_path("atomic"); + let mut scene = Scene::default(); + scene.name = "First".into(); + save_map(&path, &scene).unwrap(); + scene.name = "Second".into(); + save_map(&path, &scene).unwrap(); + + assert_eq!(load_map(&path).unwrap().name, "Second"); + fs::remove_file(path).unwrap(); + } + + #[test] + fn unsupported_versions_are_reported() { + let json = r#"{"version":999,"scene":{}}"#; + assert!(matches!( + deserialize_map(json), + Err(MapIoError::UnsupportedVersion { + found: 999, + supported: CURRENT_MAP_VERSION + }) + )); + } +} diff --git a/src/io/mod.rs b/src/io/mod.rs new file mode 100644 index 0000000..7728484 --- /dev/null +++ b/src/io/mod.rs @@ -0,0 +1,10 @@ +//! Versioned JSON map persistence. + +mod dialog; +mod map; + +pub use dialog::{choose_map_open_path, choose_map_save_path}; +pub use map::{ + CURRENT_MAP_VERSION, DEFAULT_MAP_PATH, MapDocument, MapIoError, deserialize_map, + load_default_map, load_map, save_default_map, save_map, serialize_map, +}; diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..73ba7aa --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,7 @@ +pub mod app; +pub mod editor; +pub mod io; +pub mod renderer; +pub mod scene; +pub mod stats; +pub mod ui; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..a1195f4 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,4 @@ +fn main() -> anyhow::Result<()> { + env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init(); + ray_marching_editor::app::run() +} diff --git a/src/renderer/gpu.rs b/src/renderer/gpu.rs new file mode 100644 index 0000000..df61b5b --- /dev/null +++ b/src/renderer/gpu.rs @@ -0,0 +1,684 @@ +use std::sync::{ + Arc, + mpsc::{Receiver, Sender, channel}, +}; + +use anyhow::{Context, Result, bail}; +use bytemuck::Zeroable; +use wgpu::{ + BindGroup, Buffer, CurrentSurfaceTexture, Device, Queue, RenderPipeline, Surface, + SurfaceConfiguration, TextureFormat, TextureView, +}; +use winit::{dpi::PhysicalSize, window::Window}; + +use super::gpu_types::{ + CellRangeGpu, GlobalsGpu, LightGpu, MAX_CELL_INDICES, MAX_GRID_CELLS, MAX_LIGHTS, + MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu, PrimitiveGpu, SceneGpuUpload, +}; + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct ViewportRectPx { + pub x: u32, + pub y: u32, + pub width: u32, + pub height: u32, +} + +impl ViewportRectPx { + pub fn clamped(self, target: PhysicalSize) -> Self { + let x = self.x.min(target.width); + let y = self.y.min(target.height); + Self { + x, + y, + width: self.width.min(target.width.saturating_sub(x)), + height: self.height.min(target.height.saturating_sub(y)), + } + } +} + +pub struct GpuRenderer { + surface: Surface<'static>, + device: Device, + queue: Queue, + config: SurfaceConfiguration, + pipeline: RenderPipeline, + bind_group: BindGroup, + globals_buffer: Buffer, + primitive_buffer: Buffer, + material_buffer: Buffer, + light_buffer: Buffer, + cell_range_buffer: Buffer, + cell_index_buffer: Buffer, + adapter_name: String, + supported_present_modes: Vec, + frame_timer: Option, +} + +const GPU_TIMESTAMP_BYTES: u64 = 2 * std::mem::size_of::() as u64; +const GPU_READBACK_SLOTS: usize = 3; + +struct GpuReadbackSlot { + buffer: Buffer, + busy: bool, + serial: u64, +} + +struct GpuFrameTimer { + query_set: wgpu::QuerySet, + resolve_buffer: Buffer, + readbacks: Vec, + active_slot: Option, + completed_tx: Sender<(usize, u64, bool)>, + completed_rx: Receiver<(usize, u64, bool)>, + next_serial: u64, + last_completed_serial: u64, + timestamp_period_ns: f32, + latest_ms: Option, +} + +impl GpuRenderer { + pub async fn new(window: Arc) -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let surface = instance + .create_surface(window.clone()) + .context("failed to create the Vulkan presentation surface")?; + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + force_fallback_adapter: false, + compatible_surface: Some(&surface), + }) + .await + .context("no Vulkan adapter can present to this window")?; + + let adapter_info = adapter.get_info(); + if adapter_info.backend != wgpu::Backend::Vulkan { + bail!( + "expected the Vulkan backend, got {:?}", + adapter_info.backend + ); + } + + let optional_features = adapter.features() & wgpu::Features::TIMESTAMP_QUERY; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("ray-marching-editor device"), + required_features: optional_features, + required_limits: wgpu::Limits::default(), + experimental_features: wgpu::ExperimentalFeatures::disabled(), + memory_hints: wgpu::MemoryHints::Performance, + trace: wgpu::Trace::Off, + }) + .await + .context("failed to create the Vulkan device")?; + + let size = window.inner_size(); + let capabilities = surface.get_capabilities(&adapter); + let format = capabilities + .formats + .iter() + .copied() + .find(TextureFormat::is_srgb) + .or_else(|| capabilities.formats.first().copied()) + .context("the Vulkan surface exposes no color formats")?; + // VSync is deliberately off by default. Immediate gives the GPU no + // presentation throttle; Mailbox is the tear-free uncapped fallback. + let present_mode = choose_present_mode(&capabilities.present_modes, false); + let config = SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format, + width: size.width.max(1), + height: size.height.max(1), + present_mode, + // Three or more keeps Vulkan Mailbox unlimited as well, instead + // of silently capping it to twice the monitor refresh rate. + desired_maximum_frame_latency: 3, + alpha_mode: wgpu::CompositeAlphaMode::Auto, + view_formats: vec![], + }; + surface.configure(&device, &config); + + let globals_buffer = create_buffer( + &device, + "ray marcher globals", + std::mem::size_of::() as u64, + wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + ); + let primitive_buffer = create_buffer( + &device, + "SDF primitives", + (MAX_PRIMITIVES * std::mem::size_of::()) as u64, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + ); + let material_buffer = create_buffer( + &device, + "SDF materials", + (MAX_MATERIALS * std::mem::size_of::()) as u64, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + ); + let light_buffer = create_buffer( + &device, + "scene lights", + (MAX_LIGHTS * std::mem::size_of::()) as u64, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + ); + let cell_range_buffer = create_buffer( + &device, + "spatial grid cell ranges", + (MAX_GRID_CELLS * std::mem::size_of::()) as u64, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + ); + let cell_index_buffer = create_buffer( + &device, + "spatial grid primitive indices", + (MAX_CELL_INDICES * std::mem::size_of::()) as u64, + wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + ); + + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("ray marcher bind group layout"), + entries: &[ + binding_layout_entry(0, wgpu::BufferBindingType::Uniform), + binding_layout_entry(1, wgpu::BufferBindingType::Storage { read_only: true }), + binding_layout_entry(2, wgpu::BufferBindingType::Storage { read_only: true }), + binding_layout_entry(3, wgpu::BufferBindingType::Storage { read_only: true }), + binding_layout_entry(4, wgpu::BufferBindingType::Storage { read_only: true }), + binding_layout_entry(5, wgpu::BufferBindingType::Storage { read_only: true }), + ], + }); + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("ray marcher bind group"), + layout: &bind_group_layout, + entries: &[ + buffer_entry(0, &globals_buffer), + buffer_entry(1, &primitive_buffer), + buffer_entry(2, &material_buffer), + buffer_entry(3, &cell_range_buffer), + buffer_entry(4, &cell_index_buffer), + buffer_entry(5, &light_buffer), + ], + }); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("SDF ray marcher WGSL"), + source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("ray marcher pipeline layout"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let targets = [Some(wgpu::ColorTargetState { + format, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })]; + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("fullscreen ray marcher"), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + compilation_options: Default::default(), + buffers: &[], + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + compilation_options: Default::default(), + targets: &targets, + }), + multiview_mask: None, + cache: None, + }); + + let frame_timer = optional_features + .contains(wgpu::Features::TIMESTAMP_QUERY) + .then(|| GpuFrameTimer::new(&device, &queue)); + let mut renderer = Self { + surface, + device, + queue, + config, + pipeline, + bind_group, + globals_buffer, + primitive_buffer, + material_buffer, + light_buffer, + cell_range_buffer, + cell_index_buffer, + adapter_name: adapter_info.name, + supported_present_modes: capabilities.present_modes, + frame_timer, + }; + renderer.upload(GlobalsGpu::default(), &SceneGpuUpload::default())?; + Ok(renderer) + } + + pub fn adapter_name(&self) -> &str { + &self.adapter_name + } + + pub fn device(&self) -> &Device { + &self.device + } + + pub fn queue(&self) -> &Queue { + &self.queue + } + + pub fn format(&self) -> TextureFormat { + self.config.format + } + + pub fn size(&self) -> PhysicalSize { + PhysicalSize::new(self.config.width, self.config.height) + } + + pub fn present_mode(&self) -> wgpu::PresentMode { + self.config.present_mode + } + + pub fn vsync_enabled(&self) -> bool { + is_vsync_mode(self.config.present_mode) + } + + /// Reconfigure the swapchain immediately. Returns whether the driver could + /// honor the requested state; a FIFO-only surface necessarily keeps VSync. + pub fn set_vsync(&mut self, enabled: bool) -> bool { + let present_mode = choose_present_mode(&self.supported_present_modes, enabled); + if self.config.present_mode != present_mode { + self.config.present_mode = present_mode; + self.surface.configure(&self.device, &self.config); + } + self.vsync_enabled() == enabled + } + + pub fn resize(&mut self, size: PhysicalSize) { + if size.width == 0 || size.height == 0 { + return; + } + self.config.width = size.width; + self.config.height = size.height; + self.surface.configure(&self.device, &self.config); + } + + pub fn reconfigure(&self) { + self.surface.configure(&self.device, &self.config); + } + + pub fn acquire_frame(&self) -> CurrentSurfaceTexture { + self.surface.get_current_texture() + } + + pub fn create_encoder(&self) -> wgpu::CommandEncoder { + self.device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("ray marching editor frame"), + }) + } + + /// Poll completed timestamp readbacks without waiting for the GPU. + pub fn poll_gpu_time(&mut self) -> Option { + let timer = self.frame_timer.as_mut()?; + timer.poll(&self.device) + } + + /// Reserve a free readback slot for this frame. If the GPU is more than + /// three frames behind, timing is skipped instead of stalling the editor. + pub fn prepare_gpu_timing(&mut self) { + if let Some(timer) = self.frame_timer.as_mut() { + timer.prepare_frame(); + } + } + + /// Timestamp written at the end of the final UI pass. The matching start + /// timestamp is emitted by [`Self::encode_scene`]. + pub fn gpu_frame_end_timestamp_writes(&self) -> Option> { + self.frame_timer + .as_ref() + .and_then(GpuFrameTimer::end_timestamp_writes) + } + + /// Resolve this frame's two timestamps and copy them to its staging slot. + pub fn resolve_gpu_timing(&self, encoder: &mut wgpu::CommandEncoder) { + if let Some(timer) = self.frame_timer.as_ref() { + timer.resolve(encoder); + } + } + + /// Start asynchronous CPU mapping after the command buffer was submitted. + pub fn map_gpu_timing_after_submit(&mut self) { + if let Some(timer) = self.frame_timer.as_mut() { + timer.map_after_submit(); + } + } + + pub fn upload(&mut self, mut globals: GlobalsGpu, scene: &SceneGpuUpload) -> Result<()> { + scene.validate().map_err(anyhow::Error::msg)?; + globals.counts_flags[0] = scene.primitives.len() as u32; + globals.counts_flags[1] = scene.materials.len() as u32; + globals.light_count_pad[0] = scene.lights.len() as u32; + + self.queue + .write_buffer(&self.globals_buffer, 0, bytemuck::bytes_of(&globals)); + write_slice_or_dummy( + &self.queue, + &self.primitive_buffer, + &scene.primitives, + PrimitiveGpu::zeroed(), + ); + write_slice_or_dummy( + &self.queue, + &self.material_buffer, + &scene.materials, + MaterialGpu::zeroed(), + ); + write_slice_or_dummy( + &self.queue, + &self.light_buffer, + &scene.lights, + LightGpu::zeroed(), + ); + write_slice_or_dummy( + &self.queue, + &self.cell_range_buffer, + &scene.cell_ranges, + CellRangeGpu::zeroed(), + ); + write_slice_or_dummy( + &self.queue, + &self.cell_index_buffer, + &scene.cell_indices, + 0_u32, + ); + Ok(()) + } + + pub fn encode_scene( + &self, + encoder: &mut wgpu::CommandEncoder, + target: &TextureView, + viewport: ViewportRectPx, + ) { + let viewport = viewport.clamped(self.size()); + let timestamp_writes = self + .frame_timer + .as_ref() + .and_then(GpuFrameTimer::begin_timestamp_writes); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("ray marching viewport"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color { + r: 0.018, + g: 0.021, + b: 0.028, + a: 1.0, + }), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + timestamp_writes, + occlusion_query_set: None, + multiview_mask: None, + }); + if viewport.width == 0 || viewport.height == 0 { + return; + } + pass.set_viewport( + viewport.x as f32, + viewport.y as f32, + viewport.width as f32, + viewport.height as f32, + 0.0, + 1.0, + ); + pass.set_scissor_rect(viewport.x, viewport.y, viewport.width, viewport.height); + pass.set_pipeline(&self.pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.draw(0..3, 0..1); + } + + pub fn submit(&self, encoder: wgpu::CommandEncoder) { + self.queue.submit([encoder.finish()]); + } +} + +fn choose_present_mode(modes: &[wgpu::PresentMode], vsync: bool) -> wgpu::PresentMode { + let preferences: &[wgpu::PresentMode] = if vsync { + &[ + wgpu::PresentMode::Fifo, + wgpu::PresentMode::FifoRelaxed, + wgpu::PresentMode::AutoVsync, + ] + } else { + &[ + wgpu::PresentMode::Immediate, + wgpu::PresentMode::Mailbox, + wgpu::PresentMode::AutoNoVsync, + ] + }; + preferences + .iter() + .copied() + .find(|candidate| modes.contains(candidate)) + .or_else(|| modes.first().copied()) + .unwrap_or(wgpu::PresentMode::Fifo) +} + +fn is_vsync_mode(mode: wgpu::PresentMode) -> bool { + matches!( + mode, + wgpu::PresentMode::Fifo | wgpu::PresentMode::FifoRelaxed | wgpu::PresentMode::AutoVsync + ) +} + +impl GpuFrameTimer { + fn new(device: &Device, queue: &Queue) -> Self { + let query_set = device.create_query_set(&wgpu::QuerySetDescriptor { + label: Some("frame GPU timestamps"), + ty: wgpu::QueryType::Timestamp, + count: 2, + }); + let resolve_buffer = create_buffer( + device, + "frame GPU timestamp resolve", + GPU_TIMESTAMP_BYTES, + wgpu::BufferUsages::QUERY_RESOLVE | wgpu::BufferUsages::COPY_SRC, + ); + let readbacks = (0..GPU_READBACK_SLOTS) + .map(|index| GpuReadbackSlot { + buffer: create_buffer( + device, + &format!("frame GPU timestamp readback {index}"), + GPU_TIMESTAMP_BYTES, + wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + ), + busy: false, + serial: 0, + }) + .collect(); + let (completed_tx, completed_rx) = channel(); + Self { + query_set, + resolve_buffer, + readbacks, + active_slot: None, + completed_tx, + completed_rx, + next_serial: 1, + last_completed_serial: 0, + timestamp_period_ns: queue.get_timestamp_period(), + latest_ms: None, + } + } + + fn prepare_frame(&mut self) { + if self.active_slot.is_some() { + return; + } + let Some((index, slot)) = self + .readbacks + .iter_mut() + .enumerate() + .find(|(_, slot)| !slot.busy) + else { + return; + }; + slot.busy = true; + slot.serial = self.next_serial; + self.next_serial = self.next_serial.saturating_add(1); + self.active_slot = Some(index); + } + + fn begin_timestamp_writes(&self) -> Option> { + self.active_slot.map(|_| wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: Some(0), + end_of_pass_write_index: None, + }) + } + + fn end_timestamp_writes(&self) -> Option> { + self.active_slot.map(|_| wgpu::RenderPassTimestampWrites { + query_set: &self.query_set, + beginning_of_pass_write_index: None, + end_of_pass_write_index: Some(1), + }) + } + + fn resolve(&self, encoder: &mut wgpu::CommandEncoder) { + let Some(index) = self.active_slot else { + return; + }; + encoder.resolve_query_set(&self.query_set, 0..2, &self.resolve_buffer, 0); + encoder.copy_buffer_to_buffer( + &self.resolve_buffer, + 0, + &self.readbacks[index].buffer, + 0, + GPU_TIMESTAMP_BYTES, + ); + } + + fn map_after_submit(&mut self) { + let Some(index) = self.active_slot.take() else { + return; + }; + let serial = self.readbacks[index].serial; + let completed_tx = self.completed_tx.clone(); + self.readbacks[index] + .buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + let _ = completed_tx.send((index, serial, result.is_ok())); + }); + } + + fn poll(&mut self, device: &Device) -> Option { + let _ = device.poll(wgpu::PollType::Poll); + while let Ok((index, serial, success)) = self.completed_rx.try_recv() { + let Some(slot) = self.readbacks.get_mut(index) else { + continue; + }; + if success && serial > self.last_completed_serial { + let mapped = slot.buffer.slice(..).get_mapped_range(); + let timestamps: &[u64] = bytemuck::cast_slice(&mapped); + if let [start, end, ..] = timestamps + && end >= start + && self.timestamp_period_ns.is_finite() + && self.timestamp_period_ns > 0.0 + { + self.latest_ms = + Some((*end - *start) as f32 * self.timestamp_period_ns / 1_000_000.0); + self.last_completed_serial = serial; + } + drop(mapped); + } + slot.buffer.unmap(); + slot.busy = false; + } + self.latest_ms + } +} + +fn create_buffer(device: &Device, label: &str, size: u64, usage: wgpu::BufferUsages) -> Buffer { + device.create_buffer(&wgpu::BufferDescriptor { + label: Some(label), + size, + usage, + mapped_at_creation: false, + }) +} + +fn binding_layout_entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +fn buffer_entry(binding: u32, buffer: &Buffer) -> wgpu::BindGroupEntry<'_> { + wgpu::BindGroupEntry { + binding, + resource: buffer.as_entire_binding(), + } +} + +fn write_slice_or_dummy(queue: &Queue, buffer: &Buffer, values: &[T], dummy: T) { + if values.is_empty() { + queue.write_buffer(buffer, 0, bytemuck::bytes_of(&dummy)); + } else { + queue.write_buffer(buffer, 0, bytemuck::cast_slice(values)); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vsync_is_off_by_default_when_immediate_or_mailbox_is_available() { + let modes = [ + wgpu::PresentMode::Fifo, + wgpu::PresentMode::Mailbox, + wgpu::PresentMode::Immediate, + ]; + assert_eq!( + choose_present_mode(&modes, false), + wgpu::PresentMode::Immediate + ); + assert!(!is_vsync_mode(choose_present_mode(&modes, false))); + assert_eq!(choose_present_mode(&modes, true), wgpu::PresentMode::Fifo); + } + + #[test] + fn fifo_only_driver_reports_the_unavoidable_vsync_fallback() { + let mode = choose_present_mode(&[wgpu::PresentMode::Fifo], false); + assert_eq!(mode, wgpu::PresentMode::Fifo); + assert!(is_vsync_mode(mode)); + } +} diff --git a/src/renderer/gpu_types.rs b/src/renderer/gpu_types.rs new file mode 100644 index 0000000..caf5989 --- /dev/null +++ b/src/renderer/gpu_types.rs @@ -0,0 +1,129 @@ +use bytemuck::{Pod, Zeroable}; + +pub const MAX_PRIMITIVES: usize = 128; +pub const MAX_MATERIALS: usize = 64; +pub const MAX_LIGHTS: usize = 16; +pub const MAX_GRID_CELLS: usize = 32 * 32 * 32; +pub const MAX_CELL_INDICES: usize = 131_072; + +pub const FLAG_USE_GRID: u32 = 1 << 0; +pub const FLAG_SHADOWS: u32 = 1 << 1; +pub const FLAG_AO: u32 = 1 << 2; +pub const FLAG_FOG: u32 = 1 << 3; +pub const FLAG_CLOUDS: u32 = 1 << 4; +pub const FLAG_SELECTED_HIGHLIGHT: u32 = 1 << 5; + +/// Uniform data shared by the fullscreen ray-marching pass. +/// +/// Keep this in lock-step with `Globals` in `shader.wgsl`. +#[repr(C)] +#[derive(Clone, Copy, Debug, Pod, Zeroable)] +pub struct GlobalsGpu { + pub inv_view_proj: [[f32; 4]; 4], + pub camera_pos_time: [f32; 4], + pub viewport_maxdist_epsilon: [f32; 4], + pub sun_dir_intensity: [f32; 4], + pub sun_color_ambient: [f32; 4], + pub fog_color_density: [f32; 4], + pub raymarch_params: [f32; 4], + pub grid_origin_cell_size: [f32; 4], + pub counts_flags: [u32; 4], + pub grid_dims_steps: [u32; 4], + pub light_count_pad: [u32; 4], +} + +impl Default for GlobalsGpu { + fn default() -> Self { + Self { + inv_view_proj: glam::Mat4::IDENTITY.to_cols_array_2d(), + camera_pos_time: [0.0, 0.0, 0.0, 0.0], + viewport_maxdist_epsilon: [1280.0, 720.0, 500.0, 0.001], + sun_dir_intensity: [0.35, 0.25, 0.8, 3.0], + sun_color_ambient: [1.0, 0.93, 0.82, 0.18], + fog_color_density: [0.45, 0.62, 0.82, 0.012], + raymarch_params: [0.82, 24.0, 0.65, 0.48], + grid_origin_cell_size: [-16.0, -16.0, -16.0, 4.0], + counts_flags: [ + 0, + 0, + u32::MAX, + FLAG_SHADOWS | FLAG_AO | FLAG_FOG | FLAG_CLOUDS, + ], + grid_dims_steps: [8, 8, 8, 160], + light_count_pad: [0; 4], + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)] +pub struct PrimitiveGpu { + pub center_bound: [f32; 4], + pub rotation: [f32; 4], + pub scale_smoothing: [f32; 4], + pub params0: [f32; 4], + pub params1: [f32; 4], + pub meta: [u32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)] +pub struct MaterialGpu { + pub base_color_roughness: [f32; 4], + pub emissive_metallic: [f32; 4], +} + +/// A directional sun (`kind = 0`), spot light (`kind = 1`), or point light (`kind = 2`). +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)] +pub struct LightGpu { + pub position_range: [f32; 4], + pub direction_outer_cos: [f32; 4], + pub color_intensity: [f32; 4], + pub params: [f32; 4], +} + +#[repr(C)] +#[derive(Clone, Copy, Debug, Default, Pod, Zeroable)] +pub struct CellRangeGpu { + pub offset: u32, + pub count: u32, +} + +#[derive(Clone, Debug, Default)] +pub struct SceneGpuUpload { + pub primitives: Vec, + pub materials: Vec, + pub lights: Vec, + pub cell_ranges: Vec, + pub cell_indices: Vec, +} + +impl SceneGpuUpload { + pub fn validate(&self) -> Result<(), &'static str> { + if self.primitives.len() > MAX_PRIMITIVES { + return Err("scene exceeds the GPU primitive limit"); + } + if self.materials.len() > MAX_MATERIALS { + return Err("scene exceeds the GPU material limit"); + } + if self.lights.len() > MAX_LIGHTS { + return Err("scene exceeds the GPU light limit"); + } + if self.cell_ranges.len() > MAX_GRID_CELLS { + return Err("spatial grid exceeds the GPU cell limit"); + } + if self.cell_indices.len() > MAX_CELL_INDICES { + return Err("spatial grid exceeds the GPU index limit"); + } + Ok(()) + } +} + +const _: () = { + assert!(std::mem::size_of::() == 224); + assert!(std::mem::size_of::() == 96); + assert!(std::mem::size_of::() == 32); + assert!(std::mem::size_of::() == 64); + assert!(std::mem::size_of::() == 8); +}; diff --git a/src/renderer/mod.rs b/src/renderer/mod.rs new file mode 100644 index 0000000..ff62ec4 --- /dev/null +++ b/src/renderer/mod.rs @@ -0,0 +1,13 @@ +mod gpu; +mod gpu_types; +mod scene_upload; +#[cfg(test)] +mod shader_test; + +pub use gpu::{GpuRenderer, ViewportRectPx}; +pub use gpu_types::{ + CellRangeGpu, FLAG_AO, FLAG_CLOUDS, FLAG_FOG, FLAG_SELECTED_HIGHLIGHT, FLAG_SHADOWS, + FLAG_USE_GRID, GlobalsGpu, LightGpu, MAX_LIGHTS, MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu, + PrimitiveGpu, SceneGpuUpload, +}; +pub use scene_upload::GridGpuInfo; diff --git a/src/renderer/scene_upload.rs b/src/renderer/scene_upload.rs new file mode 100644 index 0000000..c3d5ef1 --- /dev/null +++ b/src/renderer/scene_upload.rs @@ -0,0 +1,516 @@ +use glam::{UVec3, Vec3}; + +use crate::scene::{ + GridCell, LightKind, Material, Scene, SdfPrimitive, SpatialGrid, SpatialGridBuildOptions, +}; + +use super::gpu_types::{MAX_CELL_INDICES, MAX_GRID_CELLS}; +use super::{ + CellRangeGpu, LightGpu, MAX_LIGHTS, MAX_MATERIALS, MAX_PRIMITIVES, MaterialGpu, PrimitiveGpu, + SceneGpuUpload, +}; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct GridGpuInfo { + pub origin: Vec3, + pub cell_size: f32, + pub dimensions: UVec3, +} + +impl SceneGpuUpload { + /// Convert the serializable editor scene to the fixed GPU ABI. + /// + /// Grid ranges are filled separately because the CPU grid builder keeps + /// stable entity IDs while the GPU uses a compact visible-primitive index. + pub fn from_scene(scene: &Scene) -> Self { + let mut upload = Self::default(); + let mut unique_materials: Vec = Vec::new(); + + for primitive in scene + .primitives() + .iter() + .filter(|primitive| primitive.visible) + .take(MAX_PRIMITIVES) + { + let material_index = unique_materials + .iter() + .position(|material| material == &primitive.material) + .unwrap_or_else(|| { + if unique_materials.len() < MAX_MATERIALS { + unique_materials.push(primitive.material.clone()); + unique_materials.len() - 1 + } else { + 0 + } + }); + let (kind, params0, params1) = shape_gpu_params(&primitive.shape); + let bounds = primitive.bounding_sphere(); + let rotation = if primitive.transform.rotation.is_finite() + && primitive.transform.rotation.length_squared() > 1.0e-8 + { + primitive.transform.rotation.normalize() + } else { + glam::Quat::IDENTITY + }; + let scale = primitive.transform.scale.map(|value| { + if value.abs() < 1.0e-5 { + 1.0e-5_f32.copysign(value) + } else { + value + } + }); + upload.primitives.push(PrimitiveGpu { + center_bound: [ + primitive.transform.translation.x, + primitive.transform.translation.y, + primitive.transform.translation.z, + bounds.map_or(1.0e20, |bound| bound.radius), + ], + rotation: rotation.to_array(), + scale_smoothing: [scale.x, scale.y, scale.z, 0.0], + params0, + params1, + meta: [kind, material_index as u32, primitive.id, 0], + }); + } + + if unique_materials.is_empty() { + unique_materials.push(Material::default()); + } + upload.materials = unique_materials.iter().map(material_gpu).collect(); + upload.lights = scene + .lights() + .iter() + .take(MAX_LIGHTS) + .map(light_gpu) + .collect(); + upload + } + + pub fn gpu_primitive_index(&self, entity_id: u32) -> Option { + self.primitives + .iter() + .position(|primitive| primitive.meta[2] == entity_id) + .map(|index| index as u32) + } + + /// Build a dense GPU view of the scene's sparse CPU uniform grid. + /// Returns `None` when there are no finite primitives or when the compact + /// representation would exceed the fixed shader buffers; callers should + /// then use the brute-force reference path. + pub fn pack_spatial_grid( + &mut self, + scene: &Scene, + preferred_cell_size: f32, + ) -> Option { + self.cell_ranges.clear(); + self.cell_indices.clear(); + + let finite_bounds: Vec<_> = scene + .primitives() + .iter() + .filter(|primitive| primitive.visible) + .filter_map(|primitive| primitive.bounding_sphere()) + .collect(); + let first = finite_bounds.first()?; + let mut minimum = first.center - Vec3::splat(first.radius); + let mut maximum = first.center + Vec3::splat(first.radius); + for bounds in &finite_bounds[1..] { + minimum = minimum.min(bounds.center - Vec3::splat(bounds.radius)); + maximum = maximum.max(bounds.center + Vec3::splat(bounds.radius)); + } + + let extent = (maximum - minimum).max(Vec3::splat(0.001)); + let mut cell_size = preferred_cell_size.max(0.25); + cell_size = cell_size.max(extent.max_element() / 31.0); + + let min_cell = GridCell::new( + (minimum.x / cell_size).floor() as i32, + (minimum.y / cell_size).floor() as i32, + (minimum.z / cell_size).floor() as i32, + ); + let max_cell = GridCell::new( + (maximum.x / cell_size).floor() as i32, + (maximum.y / cell_size).floor() as i32, + (maximum.z / cell_size).floor() as i32, + ); + let dimensions = UVec3::new( + (max_cell.x - min_cell.x + 1).max(1) as u32, + (max_cell.y - min_cell.y + 1).max(1) as u32, + (max_cell.z - min_cell.z + 1).max(1) as u32, + ); + let cell_count = dimensions.x as usize * dimensions.y as usize * dimensions.z as usize; + if cell_count > MAX_GRID_CELLS { + return None; + } + + let grid = SpatialGrid::with_options( + scene, + SpatialGridBuildOptions { + cell_size, + max_cells_per_primitive: MAX_GRID_CELLS, + }, + ); + // The shader's global path intentionally scans planes only. A finite + // primitive in this list would therefore make GPU grid traversal + // incomplete, so fall back to brute force instead. + if grid.global_ids().iter().any(|id| { + scene + .primitive(*id) + .is_some_and(|primitive| !matches!(primitive.shape, SdfPrimitive::Plane { .. })) + }) { + return None; + } + + for z in 0..dimensions.z as i32 { + for y in 0..dimensions.y as i32 { + for x in 0..dimensions.x as i32 { + let cell = GridCell::new(min_cell.x + x, min_cell.y + y, min_cell.z + z); + let offset = self.cell_indices.len(); + for entity_id in grid.ids_in_cell(cell) { + if let Some(index) = self.gpu_primitive_index(*entity_id) { + self.cell_indices.push(index); + } + } + let count = self.cell_indices.len() - offset; + self.cell_ranges.push(CellRangeGpu { + offset: offset as u32, + count: count as u32, + }); + if self.cell_indices.len() > MAX_CELL_INDICES { + self.cell_ranges.clear(); + self.cell_indices.clear(); + return None; + } + } + } + } + + Some(GridGpuInfo { + origin: Vec3::new(min_cell.x as f32, min_cell.y as f32, min_cell.z as f32) * cell_size, + cell_size, + dimensions, + }) + } +} + +fn material_gpu(material: &Material) -> MaterialGpu { + let emissive = material.emissive_color * material.emission_strength; + MaterialGpu { + base_color_roughness: [ + material.albedo.x, + material.albedo.y, + material.albedo.z, + material.roughness.clamp(0.0, 1.0), + ], + emissive_metallic: [ + emissive.x, + emissive.y, + emissive.z, + material.metallic.clamp(0.0, 1.0), + ], + } +} + +fn light_gpu(light: &crate::scene::SceneLight) -> LightGpu { + let position = light.position(); + let direction = light.direction().normalize_or(glam::Vec3::NEG_Z); + let (kind, range, inner_cos, outer_cos) = match light.kind { + LightKind::DirectionalSun => (0.0, 0.0, 1.0, -1.0), + LightKind::Point { range } => (2.0, range.max(0.001), 1.0, -1.0), + LightKind::Spot { + range, + inner_angle_radians, + outer_angle_radians, + } => ( + 1.0, + range.max(0.001), + inner_angle_radians.cos(), + outer_angle_radians.cos(), + ), + }; + LightGpu { + position_range: [position.x, position.y, position.z, range], + direction_outer_cos: [direction.x, direction.y, direction.z, outer_cos], + color_intensity: [ + light.color.x, + light.color.y, + light.color.z, + light.intensity.max(0.0), + ], + params: [kind, inner_cos, if light.enabled { 1.0 } else { 0.0 }, 0.0], + } +} + +fn shape_gpu_params(shape: &SdfPrimitive) -> (u32, [f32; 4], [f32; 4]) { + let mut p0 = [0.0; 4]; + let p1 = [0.0; 4]; + let kind = match *shape { + SdfPrimitive::Sphere { radius } => { + p0[0] = radius; + 0 + } + SdfPrimitive::Box { half_extents } => { + p0[..3].copy_from_slice(&half_extents.to_array()); + 1 + } + SdfPrimitive::RoundedBox { + half_extents, + radius, + } => { + p0[..3].copy_from_slice(&half_extents.to_array()); + p0[3] = radius; + 2 + } + SdfPrimitive::BoxFrame { + half_extents, + edge_thickness, + } => { + p0[..3].copy_from_slice(&half_extents.to_array()); + p0[3] = edge_thickness; + 3 + } + SdfPrimitive::Plane { normal, offset } => { + p0[..3].copy_from_slice(&normal.to_array()); + p0[3] = offset; + 4 + } + SdfPrimitive::Torus { + major_radius, + minor_radius, + } => { + p0[0] = major_radius; + p0[1] = minor_radius; + 5 + } + SdfPrimitive::Link { + half_length, + major_radius, + minor_radius, + } => { + p0[0] = half_length; + p0[1] = major_radius; + p0[2] = minor_radius; + 6 + } + SdfPrimitive::Capsule { + half_height, + radius, + } => { + p0[0] = half_height; + p0[1] = radius; + 7 + } + SdfPrimitive::Cylinder { + half_height, + radius, + } => { + p0[0] = half_height; + p0[1] = radius; + 8 + } + SdfPrimitive::CappedCone { + half_height, + bottom_radius, + top_radius, + } => { + p0[0] = half_height; + p0[1] = bottom_radius; + p0[2] = top_radius; + 9 + } + SdfPrimitive::RoundedCone { + half_height, + bottom_radius, + top_radius, + } => { + p0[0] = half_height; + p0[1] = bottom_radius; + p0[2] = top_radius; + 10 + } + SdfPrimitive::Ellipsoid { radii } => { + p0[..3].copy_from_slice(&radii.to_array()); + 11 + } + SdfPrimitive::Octahedron { size } => { + p0[0] = size; + 12 + } + SdfPrimitive::TriangularPrism { + triangle_radius, + half_height, + } => { + p0[0] = triangle_radius; + p0[1] = half_height; + 13 + } + SdfPrimitive::HexagonalPrism { + hexagon_radius, + half_height, + } => { + p0[0] = hexagon_radius; + p0[1] = half_height; + 14 + } + SdfPrimitive::BlackHole { + horizon_radius, + influence_radius, + lens_strength, + spin, + } => { + p0 = [horizon_radius, influence_radius, lens_strength, spin]; + 15 + } + }; + (kind, p0, p1) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn all_cpu_shapes_have_distinct_gpu_kinds() { + let shapes = [ + SdfPrimitive::Sphere { radius: 1.0 }, + SdfPrimitive::Box { + half_extents: glam::Vec3::ONE, + }, + SdfPrimitive::RoundedBox { + half_extents: glam::Vec3::ONE, + radius: 0.1, + }, + SdfPrimitive::BoxFrame { + half_extents: glam::Vec3::ONE, + edge_thickness: 0.1, + }, + SdfPrimitive::Plane { + normal: glam::Vec3::Z, + offset: 0.0, + }, + SdfPrimitive::Torus { + major_radius: 1.0, + minor_radius: 0.2, + }, + SdfPrimitive::Link { + half_length: 0.5, + major_radius: 0.8, + minor_radius: 0.15, + }, + SdfPrimitive::Capsule { + half_height: 1.0, + radius: 0.25, + }, + SdfPrimitive::Cylinder { + half_height: 1.0, + radius: 0.5, + }, + SdfPrimitive::CappedCone { + half_height: 1.0, + bottom_radius: 0.7, + top_radius: 0.2, + }, + SdfPrimitive::RoundedCone { + half_height: 1.0, + bottom_radius: 0.7, + top_radius: 0.2, + }, + SdfPrimitive::Ellipsoid { + radii: glam::Vec3::ONE, + }, + SdfPrimitive::Octahedron { size: 1.0 }, + SdfPrimitive::TriangularPrism { + triangle_radius: 1.0, + half_height: 0.5, + }, + SdfPrimitive::HexagonalPrism { + hexagon_radius: 1.0, + half_height: 0.5, + }, + SdfPrimitive::BlackHole { + horizon_radius: 0.75, + influence_radius: 8.0, + lens_strength: 1.5, + spin: -0.4, + }, + ]; + let kinds: Vec<_> = shapes + .iter() + .map(|shape| shape_gpu_params(shape).0) + .collect(); + assert_eq!(kinds, (0..16).collect::>()); + } + + #[test] + fn black_hole_upload_uses_kind_fifteen_and_preserves_lensing_parameters() { + let mut scene = Scene::empty(); + let id = scene.add_primitive(SdfPrimitive::BlackHole { + horizon_radius: 0.8, + influence_radius: 11.0, + lens_strength: 2.25, + spin: -0.6, + }); + + let upload = SceneGpuUpload::from_scene(&scene); + let primitive = upload + .primitives + .iter() + .find(|primitive| primitive.meta[2] == id) + .unwrap(); + assert_eq!(primitive.meta[0], 15); + assert_eq!(primitive.params0, [0.8, 11.0, 2.25, -0.6]); + assert_eq!(primitive.params1, [0.0; 4]); + assert_eq!(primitive.center_bound[3], 0.8); + } + + #[test] + fn point_light_uses_kind_two_and_preserves_range() { + let mut scene = Scene::empty(); + let id = scene.add_light(LightKind::Point { range: 17.5 }); + let light = scene.light_mut(id).unwrap(); + light.transform.translation = glam::Vec3::new(1.0, 2.0, 3.0); + light.color = glam::Vec3::new(0.25, 0.5, 0.75); + light.intensity = 8.0; + + let upload = SceneGpuUpload::from_scene(&scene); + assert_eq!(upload.lights.len(), 1); + let gpu = upload.lights[0]; + assert_eq!(gpu.position_range, [1.0, 2.0, 3.0, 17.5]); + assert_eq!(gpu.color_intensity, [0.25, 0.5, 0.75, 8.0]); + assert_eq!(gpu.params, [2.0, 1.0, 1.0, 0.0]); + } + + #[test] + fn dense_gpu_grid_uses_compact_indices_and_keeps_plane_global() { + let mut scene = Scene::empty(); + let plane = scene.add_primitive(SdfPrimitive::Plane { + normal: glam::Vec3::Z, + offset: 0.0, + }); + let sphere = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 }); + scene.primitive_mut(sphere).unwrap().transform.translation = + glam::Vec3::new(-2.0, 0.0, 1.0); + let box_id = scene.add_primitive(SdfPrimitive::Box { + half_extents: glam::Vec3::ONE, + }); + scene.primitive_mut(box_id).unwrap().transform.translation = glam::Vec3::new(2.0, 0.0, 1.0); + + let mut upload = SceneGpuUpload::from_scene(&scene); + let info = upload.pack_spatial_grid(&scene, 1.0).unwrap(); + assert_eq!( + upload.cell_ranges.len(), + (info.dimensions.x * info.dimensions.y * info.dimensions.z) as usize + ); + + let plane_index = upload.gpu_primitive_index(plane).unwrap(); + let sphere_index = upload.gpu_primitive_index(sphere).unwrap(); + let box_index = upload.gpu_primitive_index(box_id).unwrap(); + assert!(!upload.cell_indices.contains(&plane_index)); + assert!(upload.cell_indices.contains(&sphere_index)); + assert!(upload.cell_indices.contains(&box_index)); + assert!(upload.cell_ranges.iter().all(|range| { + range.offset as usize + range.count as usize <= upload.cell_indices.len() + })); + } +} diff --git a/src/renderer/shader.wgsl b/src/renderer/shader.wgsl new file mode 100644 index 0000000..0fbcd4a --- /dev/null +++ b/src/renderer/shader.wgsl @@ -0,0 +1,1351 @@ +// Ray-marched editor viewport for wgpu/WGSL. +// +// CPU ABI (all offsets are bytes; all matrices are column-major): +// +// @group(0) @binding(0) Globals, uniform, 224 bytes +// 0 mat4x4 inv_view_proj +// 64 vec4 camera_pos_time (xyz = camera, w = seconds) +// 80 vec4 viewport_maxdist_epsilon (xy = pixels, z = max distance, +// w = base hit epsilon) +// 96 vec4 sun_dir_intensity (xyz = surface-to-sun direction) +// 112 vec4 sun_color_ambient (rgb = sun colour, w = ambient) +// 128 vec4 fog_color_density (rgb = fog colour, w = density) +// 144 vec4 raymarch_params (x = safety [0.1, 1.0], +// y = shadow softness, +// z = AO strength, +// w = cloud coverage [0, 1]) +// 160 vec4 grid_origin_cell_size (xyz = minimum corner, w = cell size) +// 176 vec4 counts_flags (x = primitive count [0, 128], +// y = material count [0, 64], +// z = selected object id, +// w = feature flags) +// 192 vec4 grid_dims_steps (xyz = cell dimensions, +// w = primary step limit [1, 192]) +// 208 vec4 light_count_pad (x = light count [0, 16]) +// +// Feature flags: bit 0 grid, bit 1 soft shadows, bit 2 AO, bit 3 fog, +// bit 4 clouds, bit 5 selected-object rim. +// +// @group(0) @binding(1) PrimitiveBuffer, read-only storage. +// PrimitiveGpu is 96 bytes (six 16-byte lanes): +// center_bound vec4 xyz = world centre, w = enclosing world radius +// rotation vec4 normalized quaternion, xyzw +// scale_smoothing vec4 xyz = local-to-world scale, w reserved +// params0 vec4 +// params1 vec4 +// meta vec4 kind, material id, stable object id, flags +// Primitive flag bit 0 disables the primitive; bit 1 marks editor selection. +// A finite primitive's bound must +// enclose it after rotation and scale. Plane (kind 4) is always global. +// +// @group(0) @binding(2) MaterialBuffer, read-only storage. +// MaterialGpu is 32 bytes: base_color_roughness and emissive_metallic. +// +// @group(0) @binding(3) CellRangeBuffer, read-only storage. +// One vec2 (8-byte stride) per x-major cell: offset, index count. +// @group(0) @binding(4) CellIndexBuffer, read-only storage. +// Packed u32 primitive indices (4-byte stride). Each finite primitive must be +// inserted into EVERY cell overlapped by its enclosing bound. +// @group(0) @binding(5) LightBuffer, read-only storage. +// LightGpu is 64 bytes: position_range, direction_outer_cos, color_intensity, +// params. params = (kind as f32: 0 sun / 1 spot / 2 point, inner_cos, +// enabled, reserved). Point lights ignore the direction and cone cosines. +// A light's direction is the direction in which it emits light. Bind at least +// one dummy element to bindings 1-5 when the corresponding logical count is 0. +// +// Primitive parameter layout: +// 0 sphere: params0.x radius +// 1 box: params0.xyz half extents +// 2 rounded box: params0.xyz half extents, params0.w corner radius +// 3 box frame: params0.xyz half extents, params0.w edge thickness +// 4 plane: params0.xyz local normal, params0.w local offset +// 5 torus: params0.x major radius, params0.y tube radius (local Z) +// 6 link: params0.x half length, params0.y major radius, +// params0.z tube radius +// 7 capsule: params0.x half height, params0.y radius (local Z) +// 8 capped cylinder: params0.x half height, params0.y radius (local Z) +// 9 capped cone: params0.x half height, params0.y bottom radius, +// params0.z top radius (local Z) +// 10 rounded cone: params0.x half height, params0.y bottom radius, +// params0.z top radius (local Z) +// 11 ellipsoid: params0.xyz radii +// 12 octahedron: params0.x size +// 13 triangular prism:params0.x triangle radius, params0.y half height (local Z) +// 14 hex prism: params0.x hexagon radius, params0.y half height (local Z) +// 15 black hole: params0.x horizon radius, params0.y influence radius, +// params0.z lens strength, params0.w spin around local Z +// +// Entrypoints: +// vs_main + fs_main: fullscreen colour render (one colour target). +// vs_main + fs_pick: optional R32Uint object-id pass. + +const MAX_PRIMITIVES: u32 = 128u; +const MAX_MATERIALS: u32 = 64u; +const MAX_LIGHTS: u32 = 16u; +const MAX_PRIMARY_STEPS: u32 = 192u; +const MAX_SHADOW_STEPS: u32 = 48u; +const MAX_CELL_CANDIDATES: u32 = 128u; +const INVALID_ID: u32 = 0xffffffffu; + +const BIG_DISTANCE: f32 = 1.0e20; +const SMALL_NUMBER: f32 = 1.0e-6; + +const FEATURE_GRID: u32 = 1u; +const FEATURE_SHADOWS: u32 = 2u; +const FEATURE_AO: u32 = 4u; +const FEATURE_FOG: u32 = 8u; +const FEATURE_CLOUDS: u32 = 16u; +const FEATURE_SELECTED_RIM: u32 = 32u; + +const PRIMITIVE_DISABLED: u32 = 1u; +const PRIMITIVE_SELECTED: u32 = 2u; + +const KIND_SPHERE: u32 = 0u; +const KIND_BOX: u32 = 1u; +const KIND_ROUNDED_BOX: u32 = 2u; +const KIND_BOX_FRAME: u32 = 3u; +const KIND_PLANE: u32 = 4u; +const KIND_TORUS: u32 = 5u; +const KIND_LINK: u32 = 6u; +const KIND_CAPSULE: u32 = 7u; +const KIND_CYLINDER: u32 = 8u; +const KIND_CAPPED_CONE: u32 = 9u; +const KIND_ROUNDED_CONE: u32 = 10u; +const KIND_ELLIPSOID: u32 = 11u; +const KIND_OCTAHEDRON: u32 = 12u; +const KIND_TRIANGULAR_PRISM: u32 = 13u; +const KIND_HEXAGONAL_PRISM: u32 = 14u; +const KIND_BLACK_HOLE: u32 = 15u; + +struct Globals { + inv_view_proj: mat4x4, + camera_pos_time: vec4, + viewport_maxdist_epsilon: vec4, + sun_dir_intensity: vec4, + sun_color_ambient: vec4, + fog_color_density: vec4, + raymarch_params: vec4, + grid_origin_cell_size: vec4, + counts_flags: vec4, + grid_dims_steps: vec4, + light_count_pad: vec4, +} + +struct PrimitiveGpu { + center_bound: vec4, + rotation: vec4, + scale_smoothing: vec4, + params0: vec4, + params1: vec4, + metadata: vec4, +} + +struct MaterialGpu { + base_color_roughness: vec4, + emissive_metallic: vec4, +} + +struct LightGpu { + position_range: vec4, + direction_outer_cos: vec4, + color_intensity: vec4, + params: vec4, +} + +struct PrimitiveBuffer { + data: array, +} + +struct MaterialBuffer { + data: array, +} + +struct CellRangeBuffer { + data: array>, +} + +struct CellIndexBuffer { + data: array, +} + +struct LightBuffer { + data: array, +} + +@group(0) @binding(0) +var globals: Globals; + +@group(0) @binding(1) +var primitives: PrimitiveBuffer; + +@group(0) @binding(2) +var materials: MaterialBuffer; + +@group(0) @binding(3) +var cell_ranges: CellRangeBuffer; + +@group(0) @binding(4) +var cell_indices: CellIndexBuffer; + +@group(0) @binding(5) +var lights: LightBuffer; + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +struct SceneSample { + distance: f32, + object_id: u32, + material_id: u32, + primitive_index: u32, +} + +struct RayHit { + distance: f32, + object_id: u32, + material_id: u32, + primitive_index: u32, + steps: u32, + did_hit: u32, + world_position: vec3, + ray_direction: vec3, +} + +struct BlackHoleInfluence { + step_limit: f32, + curvature: vec3, +} + +struct EvaluatedLight { + direction_to_light: vec3, + radiance: vec3, + maximum_distance: f32, + enabled: u32, +} + +struct SkySun { + direction_to_light: vec3, + radiance: vec3, +} + +fn feature_enabled(feature: u32) -> bool { + return (globals.counts_flags.w & feature) != 0u; +} + +fn primitive_count() -> u32 { + return min(globals.counts_flags.x, MAX_PRIMITIVES); +} + +fn material_count() -> u32 { + return min(globals.counts_flags.y, MAX_MATERIALS); +} + +fn light_count() -> u32 { + return min(globals.light_count_pad.x, MAX_LIGHTS); +} + +fn safe_normalize3(value: vec3) -> vec3 { + let magnitude_squared = dot(value, value); + if (magnitude_squared <= SMALL_NUMBER * SMALL_NUMBER) { + return vec3(0.0, 0.0, 1.0); + } + return value * inverseSqrt(magnitude_squared); +} + +fn quaternion_rotate(quaternion: vec4, value: vec3) -> vec3 { + let vector_part = quaternion.xyz; + let twice_cross = 2.0 * cross(vector_part, value); + return value + quaternion.w * twice_cross + cross(vector_part, twice_cross); +} + +fn quaternion_inverse_rotate(quaternion_value: vec4, value: vec3) -> vec3 { + let inverse_length = inverseSqrt(max(dot(quaternion_value, quaternion_value), SMALL_NUMBER)); + let normalized = quaternion_value * inverse_length; + let conjugate = vec4(-normalized.xyz, normalized.w); + return quaternion_rotate(conjugate, value); +} + +fn sd_sphere(position: vec3, radius: f32) -> f32 { + return length(position) - max(abs(radius), SMALL_NUMBER); +} + +fn sd_box(position: vec3, half_extents_value: vec3) -> f32 { + let half_extents = max(abs(half_extents_value), vec3(SMALL_NUMBER)); + let q = abs(position) - half_extents; + return length(max(q, vec3(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0); +} + +fn sd_rounded_box( + position: vec3, + half_extents_value: vec3, + corner_radius_value: f32, +) -> f32 { + let corner_radius = max(abs(corner_radius_value), SMALL_NUMBER); + return sd_box(position, half_extents_value) - corner_radius; +} + +fn frame_part(value: vec3) -> f32 { + return length(max(value, vec3(0.0))) + min(max(value.x, max(value.y, value.z)), 0.0); +} + +fn sd_box_frame( + position: vec3, + half_extents_value: vec3, + edge_thickness_value: f32, +) -> f32 { + let half_extents = max(abs(half_extents_value), vec3(SMALL_NUMBER)); + let edge_thickness = max(abs(edge_thickness_value), SMALL_NUMBER); + let p = abs(position) - half_extents; + let q = abs(p + vec3(edge_thickness)) - vec3(edge_thickness); + return min( + frame_part(vec3(p.x, q.y, q.z)), + min( + frame_part(vec3(q.x, p.y, q.z)), + frame_part(vec3(q.x, q.y, p.z)), + ), + ); +} + +fn sd_torus(position: vec3, major_value: f32, minor_value: f32) -> f32 { + let major_radius = max(abs(major_value), SMALL_NUMBER); + let minor_radius = max(abs(minor_value), SMALL_NUMBER); + let ring = vec2(length(position.xy) - major_radius, position.z); + return length(ring) - minor_radius; +} + +fn sd_capsule( + position: vec3, + endpoint_a: vec3, + endpoint_b: vec3, + radius_value: f32, +) -> f32 { + let point_delta = position - endpoint_a; + let segment = endpoint_b - endpoint_a; + let projection = clamp(dot(point_delta, segment) / max(dot(segment, segment), SMALL_NUMBER), 0.0, 1.0); + return length(point_delta - segment * projection) - max(abs(radius_value), SMALL_NUMBER); +} + +fn sd_capped_cylinder(position: vec3, radius_value: f32, half_height_value: f32) -> f32 { + let radius = max(abs(radius_value), SMALL_NUMBER); + let half_height = max(abs(half_height_value), SMALL_NUMBER); + let d = abs(vec2(length(position.xy), position.z)) - vec2(radius, half_height); + return min(max(d.x, d.y), 0.0) + length(max(d, vec2(0.0))); +} + +fn sd_capped_cone( + position: vec3, + bottom_radius_value: f32, + top_radius_value: f32, + half_height_value: f32, +) -> f32 { + let bottom_radius = max(abs(bottom_radius_value), SMALL_NUMBER); + let top_radius = max(abs(top_radius_value), SMALL_NUMBER); + let half_height = max(abs(half_height_value), SMALL_NUMBER); + let q = vec2(length(position.xy), position.z); + let cap_radius = select(bottom_radius, top_radius, q.y > 0.0); + let side_start = vec2(top_radius, half_height); + let side_delta = vec2(top_radius - bottom_radius, 2.0 * half_height); + let cap_vector = vec2(q.x - min(q.x, cap_radius), abs(q.y) - half_height); + let side_projection = clamp( + dot(side_start - q, side_delta) / max(dot(side_delta, side_delta), SMALL_NUMBER), + 0.0, + 1.0, + ); + let side_vector = q - side_start + side_delta * side_projection; + let sign_value = select(1.0, -1.0, side_vector.x < 0.0 && cap_vector.y < 0.0); + return sign_value * sqrt(min(dot(cap_vector, cap_vector), dot(side_vector, side_vector))); +} + +fn sd_rounded_cone( + position: vec3, + half_height_value: f32, + bottom_radius_value: f32, + top_radius_value: f32, +) -> f32 { + let half_height = max(abs(half_height_value), SMALL_NUMBER); + let bottom_radius = max(abs(bottom_radius_value), SMALL_NUMBER); + let top_radius = max(abs(top_radius_value), SMALL_NUMBER); + let height = 2.0 * half_height; + let slope = clamp((bottom_radius - top_radius) / height, -0.999999, 0.999999); + let adjacent = sqrt(max(1.0 - slope * slope, SMALL_NUMBER)); + let q = vec2(length(position.xy), position.z + half_height); + let projection = dot(q, vec2(-slope, adjacent)); + if (projection < 0.0) { + return length(q) - bottom_radius; + } + if (projection > adjacent * height) { + return length(q - vec2(0.0, height)) - top_radius; + } + return dot(q, vec2(adjacent, slope)) - bottom_radius; +} + +fn sd_ellipsoid(position: vec3, radii_value: vec3) -> f32 { + let radii = max(abs(radii_value), vec3(SMALL_NUMBER)); + let normalized_once = position / radii; + let normalized_twice = position / (radii * radii); + let k0 = length(normalized_once); + let k1 = max(length(normalized_twice), SMALL_NUMBER); + // The common ellipsoid approximation is slightly relaxed to keep sphere + // tracing conservative near strongly eccentric axes. + return 0.8 * k0 * (k0 - 1.0) / k1; +} + +fn sd_octahedron(position_value: vec3, size_value: f32) -> f32 { + let position = abs(position_value); + let size = max(abs(size_value), SMALL_NUMBER); + let plane_distance = position.x + position.y + position.z - size; + var q: vec3; + if (3.0 * position.x < plane_distance) { + q = position.xyz; + } else if (3.0 * position.y < plane_distance) { + q = position.yzx; + } else if (3.0 * position.z < plane_distance) { + q = position.zxy; + } else { + return plane_distance * 0.57735026919; + } + let edge_parameter = clamp(0.5 * (q.z - q.y + size), 0.0, size); + return length(vec3(q.x, q.y - size + edge_parameter, q.z - edge_parameter)); +} + +fn extruded_distance(planar_distance: f32, vertical_distance: f32) -> f32 { + let d = vec2(planar_distance, vertical_distance); + return length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0); +} + +fn sd_equilateral_triangle(position_value: vec2, radius_value: f32) -> f32 { + let square_root_three = 1.73205080757; + let radius = max(abs(radius_value), SMALL_NUMBER); + var position = position_value; + position.x = abs(position.x) - radius; + position.y = position.y + radius / square_root_three; + if (position.x + square_root_three * position.y > 0.0) { + position = vec2( + position.x - square_root_three * position.y, + -square_root_three * position.x - position.y, + ) * 0.5; + } + position.x = position.x - clamp(position.x, -2.0 * radius, 0.0); + return -length(position) * sign(position.y); +} + +fn sd_triangular_prism(position: vec3, radius_value: f32, half_height_value: f32) -> f32 { + let half_height = max(abs(half_height_value), SMALL_NUMBER); + let triangle = sd_equilateral_triangle(position.xy, radius_value); + return extruded_distance(triangle, abs(position.z) - half_height); +} + +fn sd_hexagonal_prism(position: vec3, radius_value: f32, half_height_value: f32) -> f32 { + let radius = max(abs(radius_value), SMALL_NUMBER); + let half_height = max(abs(half_height_value), SMALL_NUMBER); + var planar = abs(position.xy); + let axis = vec2(-0.86602540378, 0.5); + let projection = min(dot(axis, planar), 0.0); + planar = planar - 2.0 * projection * axis; + planar = planar - vec2(clamp(planar.x, -0.57735026919 * radius, 0.57735026919 * radius), radius); + let hexagon = length(planar) * sign(planar.y); + return extruded_distance(hexagon, abs(position.z) - half_height); +} + +fn sd_link( + position: vec3, + half_length_value: f32, + major_value: f32, + minor_value: f32, +) -> f32 { + let half_length = max(abs(half_length_value), 0.0); + let major_radius = max(abs(major_value), SMALL_NUMBER); + let minor_radius = max(abs(minor_value), SMALL_NUMBER); + let q = vec3(position.x, max(abs(position.y) - half_length, 0.0), position.z); + return length(vec2(length(q.xy) - major_radius, q.z)) - minor_radius; +} + +fn primitive_local_distance(kind: u32, position: vec3, params0: vec4, params1: vec4) -> f32 { + switch kind { + case KIND_SPHERE: { + return sd_sphere(position, params0.x); + } + case KIND_BOX: { + return sd_box(position, params0.xyz); + } + case KIND_ROUNDED_BOX: { + return sd_rounded_box(position, params0.xyz, params0.w); + } + case KIND_BOX_FRAME: { + return sd_box_frame(position, params0.xyz, params0.w); + } + case KIND_PLANE: { + let normal = safe_normalize3(params0.xyz); + return dot(position, normal) + params0.w; + } + case KIND_TORUS: { + return sd_torus(position, params0.x, params0.y); + } + case KIND_LINK: { + return sd_link(position, params0.x, params0.y, params0.z); + } + case KIND_CAPSULE: { + let half_height = max(abs(params0.x), SMALL_NUMBER); + return sd_capsule( + position, + vec3(0.0, 0.0, -half_height), + vec3(0.0, 0.0, half_height), + params0.y, + ); + } + case KIND_CYLINDER: { + return sd_capped_cylinder(position, params0.y, params0.x); + } + case KIND_CAPPED_CONE: { + return sd_capped_cone(position, params0.y, params0.z, params0.x); + } + case KIND_ROUNDED_CONE: { + return sd_rounded_cone(position, params0.x, params0.y, params0.z); + } + case KIND_ELLIPSOID: { + return sd_ellipsoid(position, params0.xyz); + } + case KIND_OCTAHEDRON: { + return sd_octahedron(position, params0.x); + } + case KIND_TRIANGULAR_PRISM: { + return sd_triangular_prism(position, params0.x, params0.y); + } + case KIND_HEXAGONAL_PRISM: { + return sd_hexagonal_prism(position, params0.x, params0.y); + } + case KIND_BLACK_HOLE: { + // Only the event horizon is geometry. The larger influence sphere + // is used exclusively by the curved primary-ray integrator. + return sd_sphere(position, params0.x); + } + default: { + return BIG_DISTANCE; + } + } +} + +fn primitive_world_distance(primitive_index: u32, world_position: vec3) -> f32 { + let primitive = primitives.data[primitive_index]; + let raw_scale = primitive.scale_smoothing.xyz; + let scale_sign = select(vec3(-1.0), vec3(1.0), raw_scale >= vec3(0.0)); + let absolute_scale = max(abs(raw_scale), vec3(1.0e-4)); + let signed_scale = scale_sign * absolute_scale; + let unrotated = quaternion_inverse_rotate(primitive.rotation, world_position - primitive.center_bound.xyz); + let local_position = unrotated / signed_scale; + let local_distance = primitive_local_distance(primitive.metadata.x, local_position, primitive.params0, primitive.params1); + let conservative_scale = min(absolute_scale.x, min(absolute_scale.y, absolute_scale.z)); + return local_distance * conservative_scale; +} + +fn empty_scene_sample() -> SceneSample { + return SceneSample(BIG_DISTANCE, INVALID_ID, 0u, INVALID_ID); +} + +fn consider_primitive( + primitive_index: u32, + world_position: vec3, + previous: SceneSample, +) -> SceneSample { + let primitive = primitives.data[primitive_index]; + if ((primitive.metadata.w & PRIMITIVE_DISABLED) != 0u) { + return previous; + } + + // The bound is a lower bound on distance to the enclosed finite shape. It + // is only a valid early-out while the current union distance is outside. + if (primitive.metadata.x != KIND_PLANE && primitive.center_bound.w > 0.0 && previous.distance >= 0.0) { + let sphere_lower_bound = length(world_position - primitive.center_bound.xyz) - primitive.center_bound.w; + if (sphere_lower_bound >= previous.distance) { + return previous; + } + } + + let exact_distance = primitive_world_distance(primitive_index, world_position); + if (exact_distance < previous.distance) { + return SceneSample(exact_distance, primitive.metadata.z, primitive.metadata.y, primitive_index); + } + return previous; +} + +fn evaluate_global_planes(world_position: vec3, starting_value: SceneSample) -> SceneSample { + var closest = starting_value; + let count = primitive_count(); + for (var primitive_index = 0u; primitive_index < MAX_PRIMITIVES; primitive_index = primitive_index + 1u) { + if (primitive_index >= count) { + break; + } + let primitive = primitives.data[primitive_index]; + if (primitive.metadata.x == KIND_PLANE) { + closest = consider_primitive(primitive_index, world_position, closest); + } + } + return closest; +} + +fn grid_is_valid() -> bool { + let dimensions = globals.grid_dims_steps.xyz; + return feature_enabled(FEATURE_GRID) + && globals.grid_origin_cell_size.w > SMALL_NUMBER + && all(dimensions > vec3(0u)); +} + +fn grid_maximum_corner() -> vec3 { + return globals.grid_origin_cell_size.xyz + + vec3(globals.grid_dims_steps.xyz) * globals.grid_origin_cell_size.w; +} + +fn point_inside_grid(world_position: vec3) -> bool { + let minimum_corner = globals.grid_origin_cell_size.xyz; + let maximum_corner = grid_maximum_corner(); + return all(world_position >= minimum_corner) && all(world_position < maximum_corner); +} + +fn grid_cell_for_point(world_position: vec3) -> vec3 { + let relative = floor( + (world_position - globals.grid_origin_cell_size.xyz) / globals.grid_origin_cell_size.w, + ); + return vec3(relative); +} + +fn linear_cell_index(cell: vec3) -> u32 { + let dimensions = globals.grid_dims_steps.xyz; + return cell.x + dimensions.x * (cell.y + dimensions.y * cell.z); +} + +fn evaluate_grid_cell(world_position: vec3) -> SceneSample { + let cell = grid_cell_for_point(world_position); + let range = cell_ranges.data[linear_cell_index(cell)]; + let candidate_count = min(range.y, MAX_CELL_CANDIDATES); + var closest = empty_scene_sample(); + + for (var slot = 0u; slot < MAX_CELL_CANDIDATES; slot = slot + 1u) { + if (slot >= candidate_count) { + break; + } + let primitive_index = cell_indices.data[range.x + slot]; + if (primitive_index >= primitive_count()) { + continue; + } + // Planes are global, so evaluating them through cell lists would only + // duplicate work and would make a missing cell entry correctness-critical. + if (primitives.data[primitive_index].metadata.x == KIND_PLANE) { + continue; + } + closest = consider_primitive(primitive_index, world_position, closest); + } + + return evaluate_global_planes(world_position, closest); +} + +fn evaluate_brute_force(world_position: vec3) -> SceneSample { + var closest = empty_scene_sample(); + let count = primitive_count(); + for (var primitive_index = 0u; primitive_index < MAX_PRIMITIVES; primitive_index = primitive_index + 1u) { + if (primitive_index >= count) { + break; + } + closest = consider_primitive(primitive_index, world_position, closest); + } + return closest; +} + +fn scene_sample(world_position: vec3) -> SceneSample { + if (!grid_is_valid()) { + return evaluate_brute_force(world_position); + } + if (point_inside_grid(world_position)) { + return evaluate_grid_cell(world_position); + } + // Finite primitives are guaranteed to be inside the grid. Planes remain + // visible before entering and after leaving it. + return evaluate_global_planes(world_position, empty_scene_sample()); +} + +fn scene_has_enabled_black_hole() -> bool { + let count = primitive_count(); + for (var primitive_index = 0u; primitive_index < MAX_PRIMITIVES; primitive_index = primitive_index + 1u) { + if (primitive_index >= count) { + break; + } + let primitive = primitives.data[primitive_index]; + if ( + primitive.metadata.x == KIND_BLACK_HOLE + && (primitive.metadata.w & PRIMITIVE_DISABLED) == 0u + ) { + return true; + } + } + return false; +} + +fn primary_scene_sample(world_position: vec3, curved_path: bool) -> SceneSample { + // A curved ray can leave a grid cell in a direction different from the + // direction used to choose its step. Brute force keeps the SDF union + // correct in black-hole scenes; the regular straight-ray path retains the + // spatial-grid fast path. + if (curved_path) { + return evaluate_brute_force(world_position); + } + return scene_sample(world_position); +} + +fn black_hole_influence_at( + world_position: vec3, + ray_direction: vec3, + boundary_epsilon: f32, +) -> BlackHoleInfluence { + var step_limit = BIG_DISTANCE; + var curvature = vec3(0.0); + let count = primitive_count(); + + for (var primitive_index = 0u; primitive_index < MAX_PRIMITIVES; primitive_index = primitive_index + 1u) { + if (primitive_index >= count) { + break; + } + let primitive = primitives.data[primitive_index]; + if ( + primitive.metadata.x != KIND_BLACK_HOLE + || (primitive.metadata.w & PRIMITIVE_DISABLED) != 0u + ) { + continue; + } + + let absolute_scale = max(abs(primitive.scale_smoothing.xyz), vec3(1.0e-4)); + let maximum_scale = max(absolute_scale.x, max(absolute_scale.y, absolute_scale.z)); + let local_horizon = max(abs(primitive.params0.x), SMALL_NUMBER); + let local_influence = max(abs(primitive.params0.y), local_horizon + 1.0e-4); + let horizon_radius = local_horizon * maximum_scale; + let influence_radius = local_influence * maximum_scale; + let center_to_point = world_position - primitive.center_bound.xyz; + let radial_distance_squared = dot(center_to_point, center_to_point); + let influence_squared = influence_radius * influence_radius; + + if (radial_distance_squared < influence_squared) { + // Roughly 29 integration samples across one influence radius. The + // hit epsilon remains the absolute lower bound for forward progress. + let integration_step = max(influence_radius * 0.035, boundary_epsilon * 1.5); + step_limit = min(step_limit, integration_step); + + let radial_distance = sqrt(max(radial_distance_squared, SMALL_NUMBER * SMALL_NUMBER)); + let outward = center_to_point / radial_distance; + let direction_to_center = -outward; + let transition_width = max(influence_radius - horizon_radius, influence_radius * 1.0e-4); + let depth = clamp((influence_radius - radial_distance) / transition_width, 0.0, 1.0); + let smooth_depth = depth * depth * (3.0 - 2.0 * depth); + // The softened inverse-radius term gives a recognisable lens near + // the horizon without a singularity at the centre. + let softened_radius = max( + radial_distance, + max(horizon_radius * 0.75, influence_radius * 0.02), + ); + let focusing = smooth_depth * min(influence_radius / softened_radius, 6.0); + + let rotation_length_inverse = inverseSqrt(max(dot(primitive.rotation, primitive.rotation), SMALL_NUMBER)); + let normalized_rotation = primitive.rotation * rotation_length_inverse; + let spin_axis = safe_normalize3( + quaternion_rotate(normalized_rotation, vec3(0.0, 0.0, 1.0)), + ); + let tangent_value = cross(spin_axis, outward); + let tangent_length_squared = dot(tangent_value, tangent_value); + var tangent = vec3(0.0); + if (tangent_length_squared > SMALL_NUMBER * SMALL_NUMBER) { + tangent = tangent_value * inverseSqrt(tangent_length_squared); + } + + let lens_strength = clamp(primitive.params0.z, 0.0, 24.0); + let spin_strength = clamp(primitive.params0.w, -24.0, 24.0); + curvature = curvature + + (direction_to_center * lens_strength + tangent * spin_strength) + * (focusing / max(influence_radius, SMALL_NUMBER)); + } else { + // Cap a step at the first intersection with every influence sphere; + // otherwise a distant SDF value could jump over the entire lens. + let projected = dot(center_to_point, ray_direction); + let discriminant = projected * projected + - (radial_distance_squared - influence_squared); + if (projected < 0.0 && discriminant >= 0.0) { + let entry_distance = -projected - sqrt(max(discriminant, 0.0)); + let padding = max(boundary_epsilon * 0.75, influence_radius * 1.0e-5); + step_limit = min(step_limit, max(entry_distance, 0.0) + padding); + } + } + } + + return BlackHoleInfluence(step_limit, curvature); +} + +fn bend_ray_direction( + ray_direction: vec3, + curvature: vec3, + distance_step: f32, +) -> vec3 { + // Only perpendicular acceleration changes a unit direction. Limiting its + // tangent to 0.20 bounds each integration turn to atan(0.20), about 11.3°. + let perpendicular = curvature - ray_direction * dot(curvature, ray_direction); + let requested_turn = perpendicular * distance_step; + let requested_length = length(requested_turn); + let limited_turn = requested_turn * min(1.0, 0.20 / max(requested_length, SMALL_NUMBER)); + return safe_normalize3(ray_direction + limited_turn); +} + +fn safe_ray_reciprocal(component: f32) -> f32 { + if (abs(component) < SMALL_NUMBER) { + return select(-BIG_DISTANCE, BIG_DISTANCE, component >= 0.0); + } + return 1.0 / component; +} + +fn ray_grid_entry_distance(ray_origin: vec3, ray_direction: vec3) -> f32 { + let minimum_corner = globals.grid_origin_cell_size.xyz; + let maximum_corner = grid_maximum_corner(); + let reciprocal = vec3( + safe_ray_reciprocal(ray_direction.x), + safe_ray_reciprocal(ray_direction.y), + safe_ray_reciprocal(ray_direction.z), + ); + let first = (minimum_corner - ray_origin) * reciprocal; + let second = (maximum_corner - ray_origin) * reciprocal; + let slab_minimum = min(first, second); + let slab_maximum = max(first, second); + let near_distance = max(max(slab_minimum.x, max(slab_minimum.y, slab_minimum.z)), 0.0); + let far_distance = min(slab_maximum.x, min(slab_maximum.y, slab_maximum.z)); + if (far_distance >= near_distance) { + return near_distance; + } + return BIG_DISTANCE; +} + +fn current_cell_exit_distance(world_position: vec3, ray_direction: vec3) -> f32 { + let cell = grid_cell_for_point(world_position); + let cell_minimum = globals.grid_origin_cell_size.xyz + + vec3(cell) * globals.grid_origin_cell_size.w; + let cell_maximum = cell_minimum + vec3(globals.grid_origin_cell_size.w); + var exit_distance = BIG_DISTANCE; + + if (ray_direction.x > SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_maximum.x - world_position.x) / ray_direction.x); + } else if (ray_direction.x < -SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_minimum.x - world_position.x) / ray_direction.x); + } + if (ray_direction.y > SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_maximum.y - world_position.y) / ray_direction.y); + } else if (ray_direction.y < -SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_minimum.y - world_position.y) / ray_direction.y); + } + if (ray_direction.z > SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_maximum.z - world_position.z) / ray_direction.z); + } else if (ray_direction.z < -SMALL_NUMBER) { + exit_distance = min(exit_distance, (cell_minimum.z - world_position.z) / ray_direction.z); + } + return max(exit_distance, 0.0); +} + +fn grid_step_limit( + world_position: vec3, + ray_direction: vec3, + crossing_epsilon: f32, +) -> f32 { + if (!grid_is_valid()) { + return BIG_DISTANCE; + } + if (point_inside_grid(world_position)) { + // Crossing by a tiny amount prevents repeatedly sampling the same cell + // on a shared face. This is the key condition that makes the cell-only + // candidate evaluation safe for a ray. + return current_cell_exit_distance(world_position, ray_direction) + crossing_epsilon; + } + let entry_distance = ray_grid_entry_distance(world_position, ray_direction); + if (entry_distance < BIG_DISTANCE * 0.5) { + return entry_distance + crossing_epsilon; + } + return BIG_DISTANCE; +} + +fn adaptive_hit_epsilon(ray_distance: f32) -> f32 { + let base = max(globals.viewport_maxdist_epsilon.w, 1.0e-5); + return base * (1.0 + 0.002 * max(ray_distance, 0.0)); +} + +fn next_march_step( + sample: SceneSample, + world_position: vec3, + ray_direction: vec3, + hit_epsilon: f32, +) -> f32 { + let safety = clamp(globals.raymarch_params.x, 0.1, 1.0); + var distance_step = BIG_DISTANCE; + if (sample.primitive_index != INVALID_ID) { + distance_step = abs(sample.distance) * safety; + } + let cell_limit = grid_step_limit(world_position, ray_direction, hit_epsilon * 2.0); + return max(min(distance_step, cell_limit), hit_epsilon * 1.5); +} + +fn trace_primary(ray_origin: vec3, ray_direction: vec3) -> RayHit { + let maximum_distance = max(globals.viewport_maxdist_epsilon.z, 0.01); + let configured_steps = clamp(globals.grid_dims_steps.w, 1u, MAX_PRIMARY_STEPS); + let curved_path = scene_has_enabled_black_hole(); + var travelled = 0.0; + var world_position = ray_origin; + var current_direction = safe_normalize3(ray_direction); + var completed_steps = 0u; + + for (var step_index = 0u; step_index < MAX_PRIMARY_STEPS; step_index = step_index + 1u) { + if (step_index >= configured_steps || travelled >= maximum_distance) { + break; + } + completed_steps = step_index + 1u; + let sample = primary_scene_sample(world_position, curved_path); + let epsilon = adaptive_hit_epsilon(travelled); + if (sample.primitive_index != INVALID_ID && abs(sample.distance) <= epsilon) { + return RayHit( + travelled, + sample.object_id, + sample.material_id, + sample.primitive_index, + step_index + 1u, + 1u, + world_position, + current_direction, + ); + } + + var distance_step: f32; + if (curved_path) { + let safety = clamp(globals.raymarch_params.x, 0.1, 1.0); + var sdf_step = BIG_DISTANCE; + if (sample.primitive_index != INVALID_ID) { + sdf_step = abs(sample.distance) * safety; + } + let influence = black_hole_influence_at( + world_position, + current_direction, + epsilon, + ); + distance_step = max( + min(sdf_step, influence.step_limit), + epsilon * 1.5, + ); + distance_step = min(distance_step, maximum_distance - travelled); + + let next_direction = bend_ray_direction( + current_direction, + influence.curvature, + distance_step, + ); + // Advancing along the normalized average is a stable midpoint + // approximation and avoids a visible kink at the influence edge. + let integration_direction = safe_normalize3(current_direction + next_direction); + world_position = world_position + integration_direction * distance_step; + current_direction = next_direction; + } else { + distance_step = min( + next_march_step(sample, world_position, current_direction, epsilon), + maximum_distance - travelled, + ); + world_position = world_position + current_direction * distance_step; + } + travelled = travelled + distance_step; + } + + return RayHit( + travelled, + INVALID_ID, + 0u, + INVALID_ID, + completed_steps, + 0u, + world_position, + current_direction, + ); +} + +fn primitive_normal(primitive_index: u32, world_position: vec3, epsilon: f32) -> vec3 { + // Tetrahedral gradient: four exact primitive evaluations, independent of + // which side of a grid-cell face the normal samples fall on. + let offset = max(epsilon * 2.0, 1.0e-4); + let direction_a = vec3(1.0, -1.0, -1.0); + let direction_b = vec3(-1.0, -1.0, 1.0); + let direction_c = vec3(-1.0, 1.0, -1.0); + let direction_d = vec3(1.0, 1.0, 1.0); + let gradient = direction_a * primitive_world_distance(primitive_index, world_position + direction_a * offset) + + direction_b * primitive_world_distance(primitive_index, world_position + direction_b * offset) + + direction_c * primitive_world_distance(primitive_index, world_position + direction_c * offset) + + direction_d * primitive_world_distance(primitive_index, world_position + direction_d * offset); + return safe_normalize3(gradient); +} + +fn soft_shadow( + ray_origin: vec3, + ray_direction: vec3, + maximum_distance: f32, + surface_epsilon: f32, +) -> f32 { + var visibility = 1.0; + var travelled = surface_epsilon * 4.0; + let softness = max(globals.raymarch_params.y, 0.5); + + for (var step_index = 0u; step_index < MAX_SHADOW_STEPS; step_index = step_index + 1u) { + if (travelled >= maximum_distance) { + break; + } + let world_position = ray_origin + ray_direction * travelled; + let sample = scene_sample(world_position); + let epsilon = max(surface_epsilon, adaptive_hit_epsilon(travelled)); + if (sample.primitive_index != INVALID_ID) { + let unsigned_distance = abs(sample.distance); + if (unsigned_distance <= epsilon) { + return 0.0; + } + visibility = min(visibility, softness * unsigned_distance / max(travelled, epsilon)); + } + travelled = travelled + next_march_step(sample, world_position, ray_direction, epsilon); + } + return clamp(visibility, 0.0, 1.0); +} + +fn ambient_occlusion(world_position: vec3, normal: vec3, surface_epsilon: f32) -> f32 { + var occlusion = 0.0; + var weight = 1.0; + for (var sample_index = 1u; sample_index <= 5u; sample_index = sample_index + 1u) { + let sample_distance = max(surface_epsilon * 4.0, 0.045 * f32(sample_index)); + let sample = scene_sample(world_position + normal * sample_distance); + if (sample.primitive_index != INVALID_ID) { + occlusion = occlusion + max(sample_distance - sample.distance, 0.0) * weight; + } + weight = weight * 0.55; + } + return clamp(1.0 - max(globals.raymarch_params.z, 0.0) * occlusion, 0.0, 1.0); +} + +fn material_for_id(material_id: u32) -> MaterialGpu { + if (material_id < material_count()) { + return materials.data[material_id]; + } + return MaterialGpu(vec4(0.72, 0.74, 0.78, 0.55), vec4(0.0)); +} + +fn evaluate_light(light_index: u32, world_position: vec3) -> EvaluatedLight { + let light = lights.data[light_index]; + if (light.params.z <= 0.5 || light.color_intensity.w <= 0.0) { + return EvaluatedLight(vec3(0.0, 0.0, 1.0), vec3(0.0), 0.0, 0u); + } + + let light_kind = light.params.x; + let emission_direction = safe_normalize3(light.direction_outer_cos.xyz); + let base_radiance = max(light.color_intensity.rgb, vec3(0.0)) * light.color_intensity.w; + if (light_kind < 0.5) { + // Directional-light direction is the direction travelled by its rays; + // shading needs the opposite, surface-to-light direction. + return EvaluatedLight( + -emission_direction, + base_radiance, + min(max(globals.viewport_maxdist_epsilon.z, 1.0), 500.0), + 1u, + ); + } + + let displacement_to_light = light.position_range.xyz - world_position; + let distance_to_light = length(displacement_to_light); + let light_range = max(light.position_range.w, SMALL_NUMBER); + if (distance_to_light >= light_range || distance_to_light <= SMALL_NUMBER) { + return EvaluatedLight(vec3(0.0, 0.0, 1.0), vec3(0.0), 0.0, 0u); + } + + let direction_to_light = displacement_to_light / distance_to_light; + var angular_falloff = 1.0; + if (light_kind < 1.5) { + let cone_alignment = dot(-direction_to_light, emission_direction); + let outer_cosine = clamp(light.direction_outer_cos.w, -1.0, 1.0); + let inner_cosine = clamp(light.params.y, outer_cosine + 1.0e-4, 1.0); + angular_falloff = smoothstep(outer_cosine, inner_cosine, cone_alignment); + } else if (light_kind >= 2.5) { + return EvaluatedLight(direction_to_light, vec3(0.0), distance_to_light, 0u); + } + let normalized_distance = distance_to_light / light_range; + let range_falloff = max(1.0 - normalized_distance * normalized_distance, 0.0); + let attenuation = angular_falloff * range_falloff * range_falloff + / max(distance_to_light * distance_to_light, 0.25); + if (attenuation <= SMALL_NUMBER) { + return EvaluatedLight(direction_to_light, vec3(0.0), distance_to_light, 0u); + } + return EvaluatedLight(direction_to_light, base_radiance * attenuation, distance_to_light, 1u); +} + +fn sky_sun() -> SkySun { + let count = light_count(); + if (count == 0u) { + return SkySun( + safe_normalize3(globals.sun_dir_intensity.xyz), + max(globals.sun_color_ambient.rgb, vec3(0.0)) * max(globals.sun_dir_intensity.w, 0.0), + ); + } + for (var light_index = 0u; light_index < MAX_LIGHTS; light_index = light_index + 1u) { + if (light_index >= count) { + break; + } + let light = lights.data[light_index]; + if (light.params.z > 0.5 && light.params.x < 0.5 && light.color_intensity.w > 0.0) { + return SkySun( + -safe_normalize3(light.direction_outer_cos.xyz), + max(light.color_intensity.rgb, vec3(0.0)) * light.color_intensity.w, + ); + } + } + return SkySun(vec3(0.0, 0.0, 1.0), vec3(0.0)); +} + +fn hash_2d(position: vec2) -> f32 { + var value = fract(vec3(position.x, position.y, position.x) * 0.1031); + value = value + vec3(dot(value, value.yzx + vec3(33.33))); + return fract((value.x + value.y) * value.z); +} + +fn value_noise(position: vec2) -> f32 { + let integer = floor(position); + var fraction = fract(position); + fraction = fraction * fraction * (vec2(3.0) - 2.0 * fraction); + let corner_00 = hash_2d(integer); + let corner_10 = hash_2d(integer + vec2(1.0, 0.0)); + let corner_01 = hash_2d(integer + vec2(0.0, 1.0)); + let corner_11 = hash_2d(integer + vec2(1.0, 1.0)); + return mix(mix(corner_00, corner_10, fraction.x), mix(corner_01, corner_11, fraction.x), fraction.y); +} + +fn cloud_fbm(initial_position: vec2) -> f32 { + var position = initial_position; + var amplitude = 0.5; + var result = 0.0; + for (var octave = 0u; octave < 5u; octave = octave + 1u) { + result = result + value_noise(position) * amplitude; + position = vec2( + position.x * 1.62 - position.y * 1.17, + position.x * 1.17 + position.y * 1.62, + ) + vec2(7.13, 3.71); + amplitude = amplitude * 0.5; + } + return result; +} + +fn procedural_sky(ray_direction: vec3) -> vec3 { + let upward = clamp(ray_direction.z * 0.5 + 0.5, 0.0, 1.0); + let horizon_color = vec3(0.64, 0.78, 0.94); + let zenith_color = vec3(0.075, 0.18, 0.38); + let ground_color = vec3(0.16, 0.18, 0.21); + var color = mix(horizon_color, zenith_color, pow(upward, 0.65)); + color = mix(ground_color, color, smoothstep(-0.18, 0.06, ray_direction.z)); + + let sun = sky_sun(); + let sun_direction = sun.direction_to_light; + let sun_alignment = max(dot(ray_direction, sun_direction), 0.0); + let sun_disc = pow(sun_alignment, 1400.0); + let sun_glow = pow(sun_alignment, 18.0) * 0.22; + color = color + sun.radiance * (sun_disc * 2.5 + sun_glow); + + if (feature_enabled(FEATURE_CLOUDS)) { + let horizon_safe = max(ray_direction.z + 0.28, 0.08); + let wind = vec2(globals.camera_pos_time.w * 0.018, globals.camera_pos_time.w * 0.006); + let cloud_coordinates = ray_direction.xy / horizon_safe * 1.15 + + globals.camera_pos_time.xy * 0.008 + + wind; + let broad_shape = cloud_fbm(cloud_coordinates * 0.55); + let detail = cloud_fbm(cloud_coordinates * 1.8 + vec2(11.7, -4.3)); + let noise_value = broad_shape * 0.78 + detail * 0.22; + let coverage = clamp(globals.raymarch_params.w, 0.0, 1.0); + let threshold = mix(0.78, 0.30, coverage); + let cloud_density = smoothstep(threshold, threshold + 0.16, noise_value) + * smoothstep(-0.03, 0.22, ray_direction.z); + let cloud_light = mix( + vec3(0.55, 0.60, 0.68), + vec3(1.0, 0.98, 0.93), + clamp(0.3 + 0.7 * sun_direction.z, 0.0, 1.0), + ); + color = mix(color, cloud_light, cloud_density * 0.82); + } + return color; +} + +fn direct_light_contribution( + evaluated: EvaluatedLight, + world_position: vec3, + normal: vec3, + view_direction: vec3, + base_color: vec3, + roughness: f32, + metallic: f32, + epsilon: f32, +) -> vec3 { + if (evaluated.enabled == 0u) { + return vec3(0.0); + } + let diffuse_factor = max(dot(normal, evaluated.direction_to_light), 0.0); + if (diffuse_factor <= 0.0) { + return vec3(0.0); + } + + var shadow = 1.0; + if (feature_enabled(FEATURE_SHADOWS)) { + shadow = soft_shadow( + world_position + normal * epsilon * 5.0, + evaluated.direction_to_light, + max(evaluated.maximum_distance - epsilon * 8.0, epsilon * 16.0), + epsilon, + ); + } + let half_direction = safe_normalize3(evaluated.direction_to_light + view_direction); + let specular_power = mix(128.0, 5.0, roughness); + let specular_factor = pow(max(dot(normal, half_direction), 0.0), specular_power); + let specular_color = mix(vec3(0.04), base_color, metallic); + let diffuse_color = base_color * (1.0 - metallic); + return (diffuse_color * diffuse_factor + specular_color * specular_factor) + * evaluated.radiance + * shadow; +} + +fn shade_surface(hit: RayHit) -> vec3 { + let world_position = hit.world_position; + let epsilon = adaptive_hit_epsilon(hit.distance); + let normal = primitive_normal(hit.primitive_index, world_position, epsilon); + let view_direction = -hit.ray_direction; + let hit_primitive = primitives.data[hit.primitive_index]; + + if (hit_primitive.metadata.x == KIND_BLACK_HOLE) { + // The event horizon emits and reflects no light. Editor selection is + // intentionally the sole exception so it remains manipulable. + var horizon_color = vec3(0.0); + let selected = (hit_primitive.metadata.w & PRIMITIVE_SELECTED) != 0u; + if (feature_enabled(FEATURE_SELECTED_RIM) && selected) { + let fresnel = pow(1.0 - clamp(dot(normal, view_direction), 0.0, 1.0), 3.0); + let pulse = 0.85 + 0.15 * sin(globals.camera_pos_time.w * 4.0); + let outline_amount = clamp(0.10 + fresnel * 0.90, 0.0, 1.0) * pulse; + horizon_color = mix(horizon_color, vec3(1.0, 0.42, 0.04), outline_amount); + } + return horizon_color; + } + + let material = material_for_id(hit.material_id); + let base_color = max(material.base_color_roughness.rgb, vec3(0.0)); + let roughness = clamp(material.base_color_roughness.w, 0.04, 1.0); + let metallic = clamp(material.emissive_metallic.w, 0.0, 1.0); + let emissive = max(material.emissive_metallic.rgb, vec3(0.0)); + + var ao = 1.0; + if (feature_enabled(FEATURE_AO)) { + ao = ambient_occlusion(world_position, normal, epsilon); + } + + var direct = vec3(0.0); + let count = light_count(); + if (count == 0u) { + let fallback = EvaluatedLight( + safe_normalize3(globals.sun_dir_intensity.xyz), + max(globals.sun_color_ambient.rgb, vec3(0.0)) * max(globals.sun_dir_intensity.w, 0.0), + min(max(globals.viewport_maxdist_epsilon.z, 1.0), 500.0), + select(0u, 1u, globals.sun_dir_intensity.w > 0.0), + ); + direct = direct + direct_light_contribution( + fallback, + world_position, + normal, + view_direction, + base_color, + roughness, + metallic, + epsilon, + ); + } else { + for (var light_index = 0u; light_index < MAX_LIGHTS; light_index = light_index + 1u) { + if (light_index >= count) { + break; + } + let evaluated = evaluate_light(light_index, world_position); + direct = direct + direct_light_contribution( + evaluated, + world_position, + normal, + view_direction, + base_color, + roughness, + metallic, + epsilon, + ); + } + } + let hemisphere = 0.35 + 0.65 * max(normal.z, 0.0); + let ambient = base_color + * max(globals.sun_color_ambient.rgb, vec3(0.0)) + * max(globals.sun_color_ambient.w, 0.0) + * hemisphere + * ao; + var color = direct + ambient + emissive; + + let selected_primitive = (hit_primitive.metadata.w & PRIMITIVE_SELECTED) != 0u; + if (feature_enabled(FEATURE_SELECTED_RIM) && selected_primitive) { + let fresnel = pow(1.0 - clamp(dot(normal, view_direction), 0.0, 1.0), 3.0); + let pulse = 0.85 + 0.15 * sin(globals.camera_pos_time.w * 4.0); + let outline_amount = clamp(0.10 + fresnel * 0.90, 0.0, 1.0) * pulse; + color = mix(color, vec3(1.0, 0.42, 0.04), outline_amount); + } + + if (feature_enabled(FEATURE_FOG)) { + let fog_amount = 1.0 - exp(-max(globals.fog_color_density.w, 0.0) * hit.distance); + color = mix(color, globals.fog_color_density.rgb, clamp(fog_amount, 0.0, 1.0)); + } + return color; +} + +fn ray_direction_for_uv(uv: vec2) -> vec3 { + // WGPU uses a 0..1 depth range. The fullscreen triangle's interpolated UV + // has y=1 at the top of the render target, matching clip-space y=+1 and + // the CPU Camera::viewport_ray convention. + let clip = vec4(uv.x * 2.0 - 1.0, uv.y * 2.0 - 1.0, 1.0, 1.0); + let homogeneous_world = globals.inv_view_proj * clip; + let reciprocal_w = safe_ray_reciprocal(homogeneous_world.w); + let far_world = homogeneous_world.xyz * reciprocal_w; + return safe_normalize3(far_world - globals.camera_pos_time.xyz); +} + +fn tonemap_aces(color: vec3) -> vec3 { + let non_negative = max(color, vec3(0.0)); + let numerator = non_negative * (2.51 * non_negative + vec3(0.03)); + let denominator = non_negative * (2.43 * non_negative + vec3(0.59)) + vec3(0.14); + return clamp(numerator / denominator, vec3(0.0), vec3(1.0)); +} + +@vertex +fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let positions = array, 3>( + vec2(-1.0, -1.0), + vec2(3.0, -1.0), + vec2(-1.0, 3.0), + ); + let position = positions[vertex_index]; + var output: VertexOutput; + output.position = vec4(position, 0.0, 1.0); + output.uv = position * 0.5 + vec2(0.5); + return output; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let ray_origin = globals.camera_pos_time.xyz; + let ray_direction = ray_direction_for_uv(input.uv); + let hit = trace_primary(ray_origin, ray_direction); + if (hit.did_hit == 0u) { + let sky = procedural_sky(hit.ray_direction); + return vec4(tonemap_aces(sky), 1.0); + } + let shaded = shade_surface(hit); + return vec4(tonemap_aces(shaded), 1.0); +} + +@fragment +fn fs_pick(input: VertexOutput) -> @location(0) u32 { + let ray_origin = globals.camera_pos_time.xyz; + let ray_direction = ray_direction_for_uv(input.uv); + let hit = trace_primary(ray_origin, ray_direction); + return select(INVALID_ID, hit.object_id, hit.did_hit != 0u); +} diff --git a/src/renderer/shader_test.rs b/src/renderer/shader_test.rs new file mode 100644 index 0000000..a5efc36 --- /dev/null +++ b/src/renderer/shader_test.rs @@ -0,0 +1,29 @@ +#[test] +fn wgsl_validates_and_emits_vulkan_spirv() { + let source = include_str!("shader.wgsl"); + let module = naga::front::wgsl::parse_str(source).expect("ray marcher WGSL must parse"); + let info = naga::valid::Validator::new( + naga::valid::ValidationFlags::all(), + naga::valid::Capabilities::all(), + ) + .validate(&module) + .expect("ray marcher WGSL must validate"); + + for (shader_stage, entry_point) in [ + (naga::ShaderStage::Vertex, "vs_main"), + (naga::ShaderStage::Fragment, "fs_main"), + (naga::ShaderStage::Fragment, "fs_pick"), + ] { + let words = naga::back::spv::write_vec( + &module, + &info, + &naga::back::spv::Options::default(), + Some(&naga::back::spv::PipelineOptions { + shader_stage, + entry_point: entry_point.into(), + }), + ) + .unwrap_or_else(|error| panic!("{entry_point} must emit Vulkan SPIR-V: {error}")); + assert_eq!(words.first().copied(), Some(0x0723_0203)); + } +} diff --git a/src/scene/mod.rs b/src/scene/mod.rs new file mode 100644 index 0000000..b5fb107 --- /dev/null +++ b/src/scene/mod.rs @@ -0,0 +1,19 @@ +//! Serializable scene model, CPU SDF evaluation and spatial acceleration. + +mod model; +mod primitive; +#[allow(clippy::module_inception)] +mod scene; +mod spatial_grid; +mod transform; + +pub use model::{ + CloudSettings, EditorCamera, EntityId, LightId, LightKind, LightingSettings, Material, + PrimitiveId, SceneLight, +}; +pub use primitive::{BoundingSphere, SdfPrimitive}; +pub use scene::{ + Ray, RayHit, RayMarchSettings, Scene, ScenePrimitive, SceneSample, SceneValidationError, +}; +pub use spatial_grid::{GridCell, SpatialGrid, SpatialGridBuildOptions}; +pub use transform::Transform; diff --git a/src/scene/model.rs b/src/scene/model.rs new file mode 100644 index 0000000..0774eb4 --- /dev/null +++ b/src/scene/model.rs @@ -0,0 +1,363 @@ +use glam::{Quat, Vec2, Vec3}; +use serde::{Deserialize, Serialize}; + +use super::{BoundingSphere, Transform}; + +/// IDs share one namespace so an editor can never confuse a primitive and a +/// light after save/load or duplication. +pub type EntityId = u32; +pub type PrimitiveId = EntityId; +pub type LightId = EntityId; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Material { + pub albedo: Vec3, + pub roughness: f32, + pub metallic: f32, + pub emissive_color: Vec3, + pub emission_strength: f32, +} + +impl Default for Material { + fn default() -> Self { + Self { + albedo: Vec3::splat(0.7), + roughness: 0.6, + metallic: 0.0, + emissive_color: Vec3::ONE, + emission_strength: 0.0, + } + } +} + +impl Material { + pub(crate) fn validate(&self) -> Result<(), String> { + if !self.albedo.is_finite() || self.albedo.min_element() < 0.0 { + return Err("material albedo must be finite and non-negative".into()); + } + if !self.emissive_color.is_finite() || self.emissive_color.min_element() < 0.0 { + return Err("material emissive_color must be finite and non-negative".into()); + } + if !self.roughness.is_finite() || !(0.0..=1.0).contains(&self.roughness) { + return Err("material roughness must be in 0..=1".into()); + } + if !self.metallic.is_finite() || !(0.0..=1.0).contains(&self.metallic) { + return Err("material metallic must be in 0..=1".into()); + } + if !self.emission_strength.is_finite() || self.emission_strength < 0.0 { + return Err("material emission_strength must be finite and non-negative".into()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LightKind { + DirectionalSun, + Point { + range: f32, + }, + Spot { + range: f32, + inner_angle_radians: f32, + outer_angle_radians: f32, + }, +} + +impl LightKind { + pub fn point_default() -> Self { + Self::Point { range: 25.0 } + } + + pub fn spot_default() -> Self { + Self::Spot { + range: 25.0, + inner_angle_radians: 20.0_f32.to_radians(), + outer_angle_radians: 30.0_f32.to_radians(), + } + } + + pub fn display_name(&self) -> &'static str { + match self { + Self::DirectionalSun => "Солнце", + Self::Point { .. } => "Точечный свет", + Self::Spot { .. } => "Прожектор", + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct SceneLight { + pub id: LightId, + pub name: String, + pub transform: Transform, + pub kind: LightKind, + pub color: Vec3, + pub intensity: f32, + pub enabled: bool, +} + +impl Default for SceneLight { + fn default() -> Self { + Self { + id: 0, + name: "Солнце".into(), + transform: Transform::default(), + kind: LightKind::DirectionalSun, + color: Vec3::ONE, + intensity: 4.0, + enabled: true, + } + } +} + +impl SceneLight { + /// Directional and spot lights point down their local -Z axis. A point + /// light has no direction, but returning the same stable vector keeps + /// editor helpers and the fixed GPU ABI uniform across all light kinds. + pub fn direction(&self) -> Vec3 { + let rotation = if self.transform.rotation.is_finite() + && self.transform.rotation.length_squared() > Transform::MIN_SCALE + { + self.transform.rotation.normalize() + } else { + Quat::IDENTITY + }; + rotation * -Vec3::Z + } + + pub fn position(&self) -> Vec3 { + self.transform.translation + } + + pub fn selection_sphere(&self, icon_radius: f32) -> BoundingSphere { + BoundingSphere { + center: self.position(), + radius: icon_radius.abs().max(0.01), + } + } + + pub(crate) fn validate(&self) -> Result<(), String> { + if !self.transform.is_finite() { + return Err("light transform contains invalid values".into()); + } + if !self.color.is_finite() || self.color.min_element() < 0.0 { + return Err("light color must be finite and non-negative".into()); + } + if !self.intensity.is_finite() || self.intensity < 0.0 { + return Err("light intensity must be finite and non-negative".into()); + } + match self.kind { + LightKind::DirectionalSun => {} + LightKind::Point { range } => { + if !range.is_finite() || range <= 0.0 { + return Err("point range must be finite and greater than zero".into()); + } + } + LightKind::Spot { + range, + inner_angle_radians, + outer_angle_radians, + } => { + if !range.is_finite() || range <= 0.0 { + return Err("spot range must be finite and greater than zero".into()); + } + if !inner_angle_radians.is_finite() + || !outer_angle_radians.is_finite() + || inner_angle_radians < 0.0 + || outer_angle_radians <= 0.0 + || inner_angle_radians > outer_angle_radians + || outer_angle_radians >= core::f32::consts::FRAC_PI_2 + { + return Err("spot cone angles must satisfy 0 <= inner <= outer < pi/2".into()); + } + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct LightingSettings { + pub ambient_color: Vec3, + pub ambient_intensity: f32, + pub shadow_softness: f32, + pub max_shadow_distance: f32, +} + +impl Default for LightingSettings { + fn default() -> Self { + Self { + ambient_color: Vec3::new(0.35, 0.42, 0.55), + ambient_intensity: 0.12, + shadow_softness: 12.0, + max_shadow_distance: 200.0, + } + } +} + +impl LightingSettings { + pub(crate) fn validate(&self) -> Result<(), String> { + if !self.ambient_color.is_finite() || self.ambient_color.min_element() < 0.0 { + return Err("ambient color must be finite and non-negative".into()); + } + for (name, value) in [ + ("ambient_intensity", self.ambient_intensity), + ("shadow_softness", self.shadow_softness), + ("max_shadow_distance", self.max_shadow_distance), + ] { + if !value.is_finite() || value < 0.0 { + return Err(format!("{name} must be finite and non-negative")); + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct CloudSettings { + pub enabled: bool, + pub base_height: f32, + pub thickness: f32, + pub coverage: f32, + pub density: f32, + pub scale: f32, + pub wind_direction: Vec2, + pub wind_speed: f32, + pub color: Vec3, + pub absorption: f32, +} + +impl Default for CloudSettings { + fn default() -> Self { + Self { + enabled: true, + base_height: 80.0, + thickness: 35.0, + coverage: 0.45, + density: 0.7, + scale: 0.012, + wind_direction: Vec2::new(1.0, 0.2).normalize(), + wind_speed: 1.5, + color: Vec3::ONE, + absorption: 0.6, + } + } +} + +impl CloudSettings { + pub(crate) fn validate(&self) -> Result<(), String> { + for (name, value) in [ + ("base_height", self.base_height), + ("thickness", self.thickness), + ("density", self.density), + ("scale", self.scale), + ("wind_speed", self.wind_speed), + ("absorption", self.absorption), + ] { + if !value.is_finite() || value < 0.0 { + return Err(format!("cloud {name} must be finite and non-negative")); + } + } + if !self.coverage.is_finite() || !(0.0..=1.0).contains(&self.coverage) { + return Err("cloud coverage must be in 0..=1".into()); + } + if !self.wind_direction.is_finite() || self.wind_direction.length_squared() < 1.0e-6 { + return Err("cloud wind_direction must be finite and non-zero".into()); + } + if !self.color.is_finite() || self.color.min_element() < 0.0 { + return Err("cloud color must be finite and non-negative".into()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct EditorCamera { + pub position: Vec3, + pub yaw_radians: f32, + pub pitch_radians: f32, + pub movement_speed: f32, + pub orbit_pivot: Vec3, + pub field_of_view_y_radians: f32, + pub near_plane: f32, + pub far_plane: f32, +} + +impl Default for EditorCamera { + fn default() -> Self { + Self { + position: Vec3::new(8.0, -8.0, 6.0), + yaw_radians: -45.0_f32.to_radians(), + pitch_radians: -27.938_353_f32.to_radians(), + movement_speed: 8.0, + orbit_pivot: Vec3::ZERO, + field_of_view_y_radians: 60.0_f32.to_radians(), + near_plane: 0.01, + far_plane: 2_000.0, + } + } +} + +impl EditorCamera { + pub(crate) fn validate(&self) -> Result<(), String> { + if !self.position.is_finite() || !self.orbit_pivot.is_finite() { + return Err("editor camera vectors must be finite".into()); + } + for (name, value) in [ + ("yaw_radians", self.yaw_radians), + ("pitch_radians", self.pitch_radians), + ("movement_speed", self.movement_speed), + ("field_of_view_y_radians", self.field_of_view_y_radians), + ("near_plane", self.near_plane), + ("far_plane", self.far_plane), + ] { + if !value.is_finite() { + return Err(format!("editor camera {name} must be finite")); + } + } + if self.movement_speed <= 0.0 + || self.field_of_view_y_radians <= 0.0 + || self.field_of_view_y_radians >= core::f32::consts::PI + || self.near_plane <= 0.0 + || self.far_plane <= self.near_plane + { + return Err("editor camera projection or movement settings are invalid".into()); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn point_light_has_stable_serde_shape_and_round_trips() { + let kind = LightKind::Point { range: 17.5 }; + let json = serde_json::to_string(&kind).unwrap(); + assert_eq!(json, r#"{"type":"point","range":17.5}"#); + assert_eq!(serde_json::from_str::(&json).unwrap(), kind); + } + + #[test] + fn point_light_range_must_be_positive_and_finite() { + let mut light = SceneLight { + kind: LightKind::Point { range: 0.0 }, + ..SceneLight::default() + }; + assert!(light.validate().is_err()); + + light.kind = LightKind::Point { range: f32::NAN }; + assert!(light.validate().is_err()); + + light.kind = LightKind::point_default(); + assert!(light.validate().is_ok()); + } +} diff --git a/src/scene/primitive.rs b/src/scene/primitive.rs new file mode 100644 index 0000000..8d17f39 --- /dev/null +++ b/src/scene/primitive.rs @@ -0,0 +1,844 @@ +use glam::{Vec2, Vec3}; +use serde::{Deserialize, Serialize}; + +const EPSILON: f32 = 1.0e-6; + +/// A conservative sphere used by the spatial index before evaluating an SDF. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +pub struct BoundingSphere { + pub center: Vec3, + pub radius: f32, +} + +impl BoundingSphere { + pub fn contains(self, point: Vec3) -> bool { + point.distance_squared(self.center) <= self.radius * self.radius + } + + pub fn intersects(self, other: Self) -> bool { + let radius = self.radius + other.radius; + self.center.distance_squared(other.center) <= radius * radius + } +} + +/// The built-in set of local-space signed-distance shapes. +/// +/// All finite shapes are centered at the origin. `Plane` is the only +/// unbounded primitive and is consequently placed in a spatial grid's global +/// list. The editor is Z-up: the torus normal and the height axes of capsule, +/// cylinder, cones, and prisms are local +Z before the primitive transform. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum SdfPrimitive { + Sphere { + radius: f32, + }, + Box { + half_extents: Vec3, + }, + RoundedBox { + half_extents: Vec3, + radius: f32, + }, + BoxFrame { + half_extents: Vec3, + edge_thickness: f32, + }, + Plane { + normal: Vec3, + offset: f32, + }, + Torus { + major_radius: f32, + minor_radius: f32, + }, + Link { + half_length: f32, + major_radius: f32, + minor_radius: f32, + }, + Capsule { + half_height: f32, + radius: f32, + }, + Cylinder { + half_height: f32, + radius: f32, + }, + CappedCone { + half_height: f32, + bottom_radius: f32, + top_radius: f32, + }, + RoundedCone { + half_height: f32, + bottom_radius: f32, + top_radius: f32, + }, + Ellipsoid { + radii: Vec3, + }, + Octahedron { + size: f32, + }, + TriangularPrism { + triangle_radius: f32, + half_height: f32, + }, + HexagonalPrism { + hexagon_radius: f32, + half_height: f32, + }, + BlackHole { + horizon_radius: f32, + influence_radius: f32, + lens_strength: f32, + spin: f32, + }, +} + +impl Default for SdfPrimitive { + fn default() -> Self { + Self::Sphere { radius: 1.0 } + } +} + +impl SdfPrimitive { + /// Signed distance in the primitive's local space. + pub fn signed_distance(&self, point: Vec3) -> f32 { + match *self { + Self::Sphere { radius } => point.length() - positive(radius), + Self::Box { half_extents } => sd_box(point, positive_vec3(half_extents)), + Self::RoundedBox { + half_extents, + radius, + } => sd_box(point, positive_vec3(half_extents)) - positive(radius), + Self::BoxFrame { + half_extents, + edge_thickness, + } => sd_box_frame(point, positive_vec3(half_extents), positive(edge_thickness)), + Self::Plane { normal, offset } => { + point.dot(normal.normalize_or(Vec3::Z)) + finite_or_zero(offset) + } + Self::Torus { + major_radius, + minor_radius, + } => { + let radial = Vec2::new(point.x, point.y).length() - positive(major_radius); + Vec2::new(radial, point.z).length() - positive(minor_radius) + } + Self::Link { + half_length, + major_radius, + minor_radius, + } => sd_link( + point, + positive(half_length), + positive(major_radius), + positive(minor_radius), + ), + Self::Capsule { + half_height, + radius, + } => { + let closest_z = point.z.clamp(-positive(half_height), positive(half_height)); + (point - Vec3::Z * closest_z).length() - positive(radius) + } + Self::Cylinder { + half_height, + radius, + } => sd_cylinder(point, positive(half_height), positive(radius)), + Self::CappedCone { + half_height, + bottom_radius, + top_radius, + } => sd_capped_cone( + point, + positive(half_height), + positive(bottom_radius), + positive(top_radius), + ), + Self::RoundedCone { + half_height, + bottom_radius, + top_radius, + } => sd_rounded_cone( + point, + positive(half_height), + positive(bottom_radius), + positive(top_radius), + ), + Self::Ellipsoid { radii } => sd_ellipsoid(point, positive_vec3(radii)), + Self::Octahedron { size } => sd_octahedron(point, positive(size)), + Self::TriangularPrism { + triangle_radius, + half_height, + } => sd_triangular_prism(point, positive(triangle_radius), positive(half_height)), + Self::HexagonalPrism { + hexagon_radius, + half_height, + } => sd_hexagonal_prism(point, positive(hexagon_radius), positive(half_height)), + Self::BlackHole { horizon_radius, .. } => point.length() - positive(horizon_radius), + } + } + + /// A conservative local-space bound. `None` means the primitive is + /// unbounded and must be considered globally. + pub fn bounding_sphere(&self) -> Option { + let radius = match *self { + Self::Sphere { radius } => positive(radius), + Self::Box { half_extents } => positive_vec3(half_extents).length(), + Self::RoundedBox { + half_extents, + radius, + } => positive_vec3(half_extents).length() + positive(radius), + Self::BoxFrame { half_extents, .. } => positive_vec3(half_extents).length(), + Self::Plane { .. } => return None, + Self::Torus { + major_radius, + minor_radius, + } => positive(major_radius) + positive(minor_radius), + Self::Link { + half_length, + major_radius, + minor_radius, + } => positive(half_length) + positive(major_radius) + positive(minor_radius), + Self::Capsule { + half_height, + radius, + } => positive(half_height) + positive(radius), + Self::Cylinder { + half_height, + radius, + } => positive(half_height).hypot(positive(radius)), + Self::CappedCone { + half_height, + bottom_radius, + top_radius, + } => positive(half_height).hypot(positive(bottom_radius).max(positive(top_radius))), + Self::RoundedCone { + half_height, + bottom_radius, + top_radius, + } => positive(half_height) + positive(bottom_radius).max(positive(top_radius)), + Self::Ellipsoid { radii } => positive_vec3(radii).max_element(), + Self::Octahedron { size } => positive(size), + Self::TriangularPrism { + triangle_radius, + half_height, + } => (2.0 * positive(triangle_radius)).hypot(positive(half_height)), + Self::HexagonalPrism { + hexagon_radius, + half_height, + } => { + let vertex_radius = 2.0 * positive(hexagon_radius) / 3.0_f32.sqrt(); + vertex_radius.hypot(positive(half_height)) + } + Self::BlackHole { horizon_radius, .. } => positive(horizon_radius), + }; + Some(BoundingSphere { + center: Vec3::ZERO, + radius, + }) + } + + pub fn kind_name(&self) -> &'static str { + match self { + Self::Sphere { .. } => "Сфера", + Self::Box { .. } => "Куб", + Self::RoundedBox { .. } => "Скруглённый куб", + Self::BoxFrame { .. } => "Каркас куба", + Self::Plane { .. } => "Плоскость", + Self::Torus { .. } => "Тор", + Self::Link { .. } => "Звено", + Self::Capsule { .. } => "Капсула", + Self::Cylinder { .. } => "Цилиндр", + Self::CappedCone { .. } => "Усечённый конус", + Self::RoundedCone { .. } => "Скруглённый конус", + Self::Ellipsoid { .. } => "Эллипсоид", + Self::Octahedron { .. } => "Октаэдр", + Self::TriangularPrism { .. } => "Треугольная призма", + Self::HexagonalPrism { .. } => "Шестиугольная призма", + Self::BlackHole { .. } => "Чёрная дыра", + } + } + + pub(crate) fn validate_parameters(&self) -> Result<(), String> { + let positive_scalar = |name: &str, value: f32| { + if value.is_finite() && value > 0.0 { + Ok(()) + } else { + Err(format!("{name} must be finite and greater than zero")) + } + }; + let positive_vector = |name: &str, value: Vec3| { + if value.is_finite() && value.min_element() > 0.0 { + Ok(()) + } else { + Err(format!("{name} must contain finite, positive components")) + } + }; + + match *self { + Self::Sphere { radius } => positive_scalar("radius", radius), + Self::Box { half_extents } => positive_vector("half_extents", half_extents), + Self::RoundedBox { + half_extents, + radius, + } => { + positive_vector("half_extents", half_extents)?; + positive_scalar("radius", radius) + } + Self::BoxFrame { + half_extents, + edge_thickness, + } => { + positive_vector("half_extents", half_extents)?; + positive_scalar("edge_thickness", edge_thickness) + } + Self::Plane { normal, offset } => { + if !normal.is_finite() || normal.length_squared() <= EPSILON { + return Err("normal must be finite and non-zero".into()); + } + if !offset.is_finite() { + return Err("offset must be finite".into()); + } + Ok(()) + } + Self::Torus { + major_radius, + minor_radius, + } => { + positive_scalar("major_radius", major_radius)?; + positive_scalar("minor_radius", minor_radius) + } + Self::Link { + half_length, + major_radius, + minor_radius, + } => { + positive_scalar("half_length", half_length)?; + positive_scalar("major_radius", major_radius)?; + positive_scalar("minor_radius", minor_radius) + } + Self::Capsule { + half_height, + radius, + } + | Self::Cylinder { + half_height, + radius, + } => { + positive_scalar("half_height", half_height)?; + positive_scalar("radius", radius) + } + Self::CappedCone { + half_height, + bottom_radius, + top_radius, + } + | Self::RoundedCone { + half_height, + bottom_radius, + top_radius, + } => { + positive_scalar("half_height", half_height)?; + positive_scalar("bottom_radius", bottom_radius)?; + positive_scalar("top_radius", top_radius) + } + Self::Ellipsoid { radii } => positive_vector("radii", radii), + Self::Octahedron { size } => positive_scalar("size", size), + Self::TriangularPrism { + triangle_radius, + half_height, + } => { + positive_scalar("triangle_radius", triangle_radius)?; + positive_scalar("half_height", half_height) + } + Self::HexagonalPrism { + hexagon_radius, + half_height, + } => { + positive_scalar("hexagon_radius", hexagon_radius)?; + positive_scalar("half_height", half_height) + } + Self::BlackHole { + horizon_radius, + influence_radius, + lens_strength, + spin, + } => { + positive_scalar("horizon_radius", horizon_radius)?; + if !influence_radius.is_finite() || influence_radius <= horizon_radius { + return Err( + "influence_radius must be finite and greater than horizon_radius".into(), + ); + } + if !lens_strength.is_finite() || lens_strength < 0.0 { + return Err("lens_strength must be finite and non-negative".into()); + } + if !spin.is_finite() { + return Err("spin must be finite".into()); + } + Ok(()) + } + } + } +} + +fn sd_box(point: Vec3, half_extents: Vec3) -> f32 { + let q = point.abs() - half_extents; + q.max(Vec3::ZERO).length() + q.max_element().min(0.0) +} + +fn sd_box_frame(point: Vec3, half_extents: Vec3, edge: f32) -> f32 { + let p = point.abs() - half_extents; + let q = (p + Vec3::splat(edge)).abs() - Vec3::splat(edge); + let x = Vec3::new(p.x, q.y, q.z); + let y = Vec3::new(q.x, p.y, q.z); + let z = Vec3::new(q.x, q.y, p.z); + frame_part(x).min(frame_part(y)).min(frame_part(z)) +} + +fn frame_part(value: Vec3) -> f32 { + value.max(Vec3::ZERO).length() + value.max_element().min(0.0) +} + +fn sd_link(point: Vec3, half_length: f32, major_radius: f32, minor_radius: f32) -> f32 { + let q = Vec3::new(point.x, (point.y.abs() - half_length).max(0.0), point.z); + Vec2::new(Vec2::new(q.x, q.y).length() - major_radius, q.z).length() - minor_radius +} + +fn sd_cylinder(point: Vec3, half_height: f32, radius: f32) -> f32 { + let d = Vec2::new( + Vec2::new(point.x, point.y).length() - radius, + point.z.abs() - half_height, + ); + d.max(Vec2::ZERO).length() + d.max_element().min(0.0) +} + +fn sd_capped_cone(point: Vec3, half_height: f32, bottom_radius: f32, top_radius: f32) -> f32 { + let q = Vec2::new(Vec2::new(point.x, point.y).length(), point.z); + let k1 = Vec2::new(top_radius, half_height); + let k2 = Vec2::new(top_radius - bottom_radius, 2.0 * half_height); + let cap_radius = if q.y < 0.0 { bottom_radius } else { top_radius }; + let ca = Vec2::new(q.x - q.x.min(cap_radius), q.y.abs() - half_height); + let denominator = k2.length_squared().max(EPSILON); + let cb = q - k1 + k2 * ((k1 - q).dot(k2) / denominator).clamp(0.0, 1.0); + let sign = if cb.x < 0.0 && ca.y < 0.0 { -1.0 } else { 1.0 }; + sign * ca.length_squared().min(cb.length_squared()).sqrt() +} + +fn sd_rounded_cone(point: Vec3, half_height: f32, bottom_radius: f32, top_radius: f32) -> f32 { + let height = 2.0 * half_height; + let q = Vec2::new(Vec2::new(point.x, point.y).length(), point.z + half_height); + let radius_delta = bottom_radius - top_radius; + if radius_delta.abs() >= height { + return if bottom_radius >= top_radius { + q.length() - bottom_radius + } else { + (q - Vec2::new(0.0, height)).length() - top_radius + }; + } + let slope = radius_delta / height; + let adjacent = (1.0 - slope * slope).sqrt(); + let k = q.dot(Vec2::new(-slope, adjacent)); + if k < 0.0 { + q.length() - bottom_radius + } else if k > adjacent * height { + (q - Vec2::new(0.0, height)).length() - top_radius + } else { + q.dot(Vec2::new(adjacent, slope)) - bottom_radius + } +} + +fn sd_ellipsoid(point: Vec3, radii: Vec3) -> f32 { + let k0 = (point / radii).length(); + let k1 = (point / (radii * radii)).length(); + if k1 <= EPSILON { + -radii.min_element() + } else { + k0 * (k0 - 1.0) / k1 + } +} + +fn sd_octahedron(point: Vec3, size: f32) -> f32 { + let p = point.abs(); + let m = p.x + p.y + p.z - size; + let q = if 3.0 * p.x < m { + p + } else if 3.0 * p.y < m { + Vec3::new(p.y, p.z, p.x) + } else if 3.0 * p.z < m { + Vec3::new(p.z, p.x, p.y) + } else { + return m * 0.577_350_26; + }; + let k = (0.5 * (q.z - q.y + size)).clamp(0.0, size); + Vec3::new(q.x, q.y - size + k, q.z - k).length() +} + +fn sd_equilateral_triangle(mut point: Vec2, radius: f32) -> f32 { + const SQRT_3: f32 = 1.732_050_8; + point.x = point.x.abs() - radius; + point.y += radius / SQRT_3; + if point.x + SQRT_3 * point.y > 0.0 { + point = Vec2::new(point.x - SQRT_3 * point.y, -SQRT_3 * point.x - point.y) * 0.5; + } + point.x -= point.x.clamp(-2.0 * radius, 0.0); + -point.length() * point.y.signum() +} + +fn sd_triangular_prism(point: Vec3, triangle_radius: f32, half_height: f32) -> f32 { + let triangle = sd_equilateral_triangle(Vec2::new(point.x, point.y), triangle_radius); + extrude_distance(triangle, point.z.abs() - half_height) +} + +fn sd_hexagon(mut point: Vec2, radius: f32) -> f32 { + const KX: f32 = -0.866_025_4; + const KY: f32 = 0.5; + const KZ: f32 = 0.577_350_26; + point = point.abs(); + let projection = (Vec2::new(KX, KY).dot(point)).min(0.0); + point -= 2.0 * projection * Vec2::new(KX, KY); + point -= Vec2::new(point.x.clamp(-KZ * radius, KZ * radius), radius); + point.length() * point.y.signum() +} + +fn sd_hexagonal_prism(point: Vec3, hexagon_radius: f32, half_height: f32) -> f32 { + let hexagon = sd_hexagon(Vec2::new(point.x, point.y), hexagon_radius); + extrude_distance(hexagon, point.z.abs() - half_height) +} + +fn extrude_distance(planar_distance: f32, vertical_distance: f32) -> f32 { + let d = Vec2::new(planar_distance, vertical_distance); + d.max(Vec2::ZERO).length() + d.max_element().min(0.0) +} + +fn positive(value: f32) -> f32 { + if value.is_finite() { + value.abs().max(EPSILON) + } else { + EPSILON + } +} + +fn positive_vec3(value: Vec3) -> Vec3 { + value.map(positive) +} + +fn finite_or_zero(value: f32) -> f32 { + if value.is_finite() { value } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn approx_eq(left: f32, right: f32) { + assert!((left - right).abs() < 1.0e-5, "{left} != {right}"); + } + + #[test] + fn sphere_box_and_plane_distances_are_signed() { + let sphere = SdfPrimitive::Sphere { radius: 2.0 }; + approx_eq(sphere.signed_distance(Vec3::ZERO), -2.0); + approx_eq(sphere.signed_distance(Vec3::X * 3.0), 1.0); + + let cube = SdfPrimitive::Box { + half_extents: Vec3::ONE, + }; + approx_eq(cube.signed_distance(Vec3::ZERO), -1.0); + approx_eq(cube.signed_distance(Vec3::splat(2.0)), 3.0_f32.sqrt()); + + let plane = SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.5, + }; + approx_eq(plane.signed_distance(Vec3::new(0.0, 0.0, 1.0)), 1.5); + approx_eq(plane.signed_distance(Vec3::new(0.0, 0.0, -1.0)), -0.5); + } + + #[test] + fn all_sixteen_shape_variants_evaluate_finite_distances() { + let shapes = vec![ + SdfPrimitive::Sphere { radius: 1.0 }, + SdfPrimitive::Box { + half_extents: Vec3::ONE, + }, + SdfPrimitive::RoundedBox { + half_extents: Vec3::ONE, + radius: 0.2, + }, + SdfPrimitive::BoxFrame { + half_extents: Vec3::ONE, + edge_thickness: 0.1, + }, + SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.0, + }, + SdfPrimitive::Torus { + major_radius: 1.0, + minor_radius: 0.2, + }, + SdfPrimitive::Link { + half_length: 0.5, + major_radius: 1.0, + minor_radius: 0.2, + }, + SdfPrimitive::Capsule { + half_height: 1.0, + radius: 0.5, + }, + SdfPrimitive::Cylinder { + half_height: 1.0, + radius: 0.5, + }, + SdfPrimitive::CappedCone { + half_height: 1.0, + bottom_radius: 1.0, + top_radius: 0.2, + }, + SdfPrimitive::RoundedCone { + half_height: 1.0, + bottom_radius: 0.8, + top_radius: 0.3, + }, + SdfPrimitive::Ellipsoid { + radii: Vec3::new(1.0, 2.0, 0.5), + }, + SdfPrimitive::Octahedron { size: 1.0 }, + SdfPrimitive::TriangularPrism { + triangle_radius: 1.0, + half_height: 1.0, + }, + SdfPrimitive::HexagonalPrism { + hexagon_radius: 1.0, + half_height: 1.0, + }, + SdfPrimitive::BlackHole { + horizon_radius: 0.75, + influence_radius: 6.0, + lens_strength: 1.5, + spin: 0.4, + }, + ]; + + assert_eq!(shapes.len(), 16); + for shape in shapes { + assert!(shape.signed_distance(Vec3::new(0.1, 0.2, 0.3)).is_finite()); + if !matches!(shape, SdfPrimitive::Plane { .. }) { + assert!(shape.bounding_sphere().is_some()); + } + } + } + + #[test] + fn conservative_bounds_contain_representative_extreme_points() { + let cases = [ + ( + SdfPrimitive::Sphere { radius: 2.0 }, + Vec3::new(2.0, 0.0, 0.0), + ), + ( + SdfPrimitive::Box { + half_extents: Vec3::new(1.0, 2.0, 3.0), + }, + Vec3::new(1.0, 2.0, 3.0), + ), + ( + SdfPrimitive::RoundedBox { + half_extents: Vec3::new(1.0, 2.0, 3.0), + radius: 0.5, + }, + Vec3::new(1.5, 2.0, 3.0), + ), + ( + SdfPrimitive::BoxFrame { + half_extents: Vec3::new(1.0, 2.0, 3.0), + edge_thickness: 0.2, + }, + Vec3::new(1.0, 2.0, 3.0), + ), + ( + SdfPrimitive::Torus { + major_radius: 2.0, + minor_radius: 0.5, + }, + Vec3::new(2.5, 0.0, 0.0), + ), + ( + SdfPrimitive::Link { + half_length: 1.0, + major_radius: 2.0, + minor_radius: 0.5, + }, + Vec3::new(0.0, 3.5, 0.0), + ), + ( + SdfPrimitive::Capsule { + half_height: 2.0, + radius: 0.5, + }, + Vec3::new(0.0, 0.0, 2.5), + ), + ( + SdfPrimitive::Cylinder { + half_height: 2.0, + radius: 0.5, + }, + Vec3::new(0.5, 0.0, 2.0), + ), + ( + SdfPrimitive::CappedCone { + half_height: 2.0, + bottom_radius: 1.0, + top_radius: 0.5, + }, + Vec3::new(1.0, 0.0, -2.0), + ), + ( + SdfPrimitive::RoundedCone { + half_height: 2.0, + bottom_radius: 1.0, + top_radius: 0.5, + }, + Vec3::new(0.0, 0.0, -3.0), + ), + ( + SdfPrimitive::Ellipsoid { + radii: Vec3::new(1.0, 2.0, 3.0), + }, + Vec3::new(0.0, 0.0, 3.0), + ), + ( + SdfPrimitive::Octahedron { size: 2.0 }, + Vec3::new(2.0, 0.0, 0.0), + ), + ( + SdfPrimitive::TriangularPrism { + triangle_radius: 1.0, + half_height: 2.0, + }, + Vec3::new(0.0, 0.0, 2.0), + ), + ( + SdfPrimitive::HexagonalPrism { + hexagon_radius: 1.0, + half_height: 2.0, + }, + Vec3::new(2.0 / 3.0_f32.sqrt(), 0.0, 2.0), + ), + ( + SdfPrimitive::BlackHole { + horizon_radius: 2.0, + influence_radius: 10.0, + lens_strength: 1.5, + spin: -0.25, + }, + Vec3::new(2.0, 0.0, 0.0), + ), + ]; + + for (shape, point) in cases { + let bounds = shape.bounding_sphere().unwrap(); + assert!( + point.distance(bounds.center) <= bounds.radius + 1.0e-5, + "{} bound does not contain {point:?}", + shape.kind_name() + ); + } + assert!( + SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.0 + } + .bounding_sphere() + .is_none() + ); + } + + #[test] + fn black_hole_uses_the_horizon_as_its_sdf_and_conservative_bound() { + let black_hole = SdfPrimitive::BlackHole { + horizon_radius: 2.0, + influence_radius: 12.0, + lens_strength: 1.25, + spin: -0.5, + }; + + approx_eq(black_hole.signed_distance(Vec3::ZERO), -2.0); + approx_eq(black_hole.signed_distance(Vec3::X * 2.0), 0.0); + approx_eq(black_hole.signed_distance(Vec3::Z * 5.0), 3.0); + assert_eq!(black_hole.bounding_sphere().unwrap().radius, 2.0); + } + + #[test] + fn black_hole_parameters_are_strictly_validated() { + let black_hole = + |horizon_radius, influence_radius, lens_strength, spin| SdfPrimitive::BlackHole { + horizon_radius, + influence_radius, + lens_strength, + spin, + }; + let valid = black_hole(1.0, 8.0, 0.0, -2.5); + assert!(valid.validate_parameters().is_ok()); + + for invalid in [ + black_hole(0.0, 8.0, 0.0, -2.5), + black_hole(f32::NAN, 8.0, 0.0, -2.5), + black_hole(1.0, 1.0, 0.0, -2.5), + black_hole(1.0, f32::INFINITY, 0.0, -2.5), + black_hole(1.0, 8.0, -0.01, -2.5), + black_hole(1.0, 8.0, f32::NAN, -2.5), + black_hole(1.0, 8.0, 0.0, f32::INFINITY), + ] { + assert!(invalid.validate_parameters().is_err(), "{invalid:?}"); + } + } + + #[test] + fn axial_primitives_use_local_z_as_height() { + let capsule = SdfPrimitive::Capsule { + half_height: 2.0, + radius: 0.5, + }; + approx_eq(capsule.signed_distance(Vec3::new(0.0, 0.0, 2.5)), 0.0); + approx_eq(capsule.signed_distance(Vec3::new(0.5, 0.0, 0.0)), 0.0); + + let cylinder = SdfPrimitive::Cylinder { + half_height: 2.0, + radius: 0.5, + }; + approx_eq(cylinder.signed_distance(Vec3::new(0.0, 0.0, 2.0)), 0.0); + approx_eq(cylinder.signed_distance(Vec3::new(0.5, 0.0, 0.0)), 0.0); + + let capped_cone = SdfPrimitive::CappedCone { + half_height: 2.0, + bottom_radius: 1.0, + top_radius: 0.5, + }; + approx_eq(capped_cone.signed_distance(Vec3::new(1.0, 0.0, -2.0)), 0.0); + approx_eq(capped_cone.signed_distance(Vec3::new(0.5, 0.0, 2.0)), 0.0); + + let triangular_prism = SdfPrimitive::TriangularPrism { + triangle_radius: 1.0, + half_height: 2.0, + }; + approx_eq( + triangular_prism.signed_distance(Vec3::new(0.0, 0.0, 2.0)), + 0.0, + ); + + let hexagonal_prism = SdfPrimitive::HexagonalPrism { + hexagon_radius: 1.0, + half_height: 2.0, + }; + approx_eq( + hexagonal_prism.signed_distance(Vec3::new(0.0, 0.0, 2.0)), + 0.0, + ); + } +} diff --git a/src/scene/scene.rs b/src/scene/scene.rs new file mode 100644 index 0000000..8dda5e0 --- /dev/null +++ b/src/scene/scene.rs @@ -0,0 +1,582 @@ +use std::collections::HashSet; + +use glam::{Quat, Vec3}; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::{ + BoundingSphere, CloudSettings, EditorCamera, EntityId, LightId, LightKind, LightingSettings, + Material, PrimitiveId, SceneLight, SdfPrimitive, Transform, +}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct ScenePrimitive { + pub id: PrimitiveId, + pub name: String, + pub transform: Transform, + pub shape: SdfPrimitive, + pub material: Material, + pub visible: bool, +} + +impl Default for ScenePrimitive { + fn default() -> Self { + Self { + id: 0, + name: "Сфера".into(), + transform: Transform::default(), + shape: SdfPrimitive::default(), + material: Material::default(), + visible: true, + } + } +} + +impl ScenePrimitive { + /// A conservative world-space distance under arbitrary non-uniform scale. + pub fn signed_distance(&self, world_point: Vec3) -> f32 { + let local_point = self.transform.world_to_local(world_point); + self.shape.signed_distance(local_point) * self.transform.minimum_abs_scale() + } + + pub fn bounding_sphere(&self) -> Option { + self.shape.bounding_sphere().map(|local| BoundingSphere { + center: self.transform.local_to_world(local.center), + radius: local.radius * self.transform.maximum_abs_scale(), + }) + } + + pub fn normal(&self, world_point: Vec3, epsilon: f32) -> Vec3 { + let epsilon = epsilon.abs().max(1.0e-5); + let x = Vec3::X * epsilon; + let y = Vec3::Y * epsilon; + let z = Vec3::Z * epsilon; + Vec3::new( + self.signed_distance(world_point + x) - self.signed_distance(world_point - x), + self.signed_distance(world_point + y) - self.signed_distance(world_point - y), + self.signed_distance(world_point + z) - self.signed_distance(world_point - z), + ) + .normalize_or(Vec3::Z) + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SceneSample { + pub distance: f32, + pub primitive_id: Option, +} + +impl SceneSample { + pub const EMPTY: Self = Self { + distance: f32::INFINITY, + primitive_id: None, + }; +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Ray { + pub origin: Vec3, + pub direction: Vec3, +} + +impl Ray { + pub fn new(origin: Vec3, direction: Vec3) -> Option { + if !origin.is_finite() || !direction.is_finite() || direction.length_squared() < 1.0e-12 { + return None; + } + Some(Self { + origin, + direction: direction.normalize(), + }) + } + + pub fn point_at(self, distance: f32) -> Vec3 { + self.origin + self.direction * distance + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RayMarchSettings { + pub max_steps: u32, + pub max_distance: f32, + pub surface_epsilon: f32, + pub normal_epsilon: f32, +} + +impl Default for RayMarchSettings { + fn default() -> Self { + Self { + max_steps: 192, + max_distance: 2_000.0, + surface_epsilon: 1.0e-3, + normal_epsilon: 2.0e-3, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RayHit { + pub primitive_id: PrimitiveId, + pub position: Vec3, + pub normal: Vec3, + pub distance: f32, + pub steps: u32, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Scene { + #[serde(default = "default_scene_name")] + pub name: String, + #[serde(default)] + pub primitives: Vec, + #[serde(default)] + pub lights: Vec, + #[serde(default)] + pub lighting: LightingSettings, + #[serde(default)] + pub clouds: CloudSettings, + #[serde(default)] + pub editor_camera: EditorCamera, + #[serde(default = "default_background_color")] + pub background_color: Vec3, + #[serde(default = "first_entity_id")] + next_entity_id: EntityId, +} + +impl Default for Scene { + fn default() -> Self { + let mut scene = Self::empty_named(default_scene_name()); + + let sun_id = scene.add_light(LightKind::DirectionalSun); + if let Some(sun) = scene.light_mut(sun_id) { + sun.name = "Солнце".into(); + let ray_direction = Vec3::new(-0.35, -0.25, -1.0).normalize(); + sun.transform.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, ray_direction); + sun.intensity = 5.0; + sun.color = Vec3::new(1.0, 0.94, 0.82); + } + + let spot_id = scene.add_light(LightKind::spot_default()); + if let Some(spot) = scene.light_mut(spot_id) { + spot.name = "Прожектор".into(); + spot.transform.translation = Vec3::new(4.0, 4.0, 6.0); + let target = Vec3::new(0.0, 0.0, 0.5); + let ray_direction = (target - spot.transform.translation).normalize(); + spot.transform.rotation = Quat::from_rotation_arc(Vec3::NEG_Z, ray_direction); + spot.intensity = 1_500.0; + } + + scene + } +} + +impl Scene { + pub fn new(name: impl Into) -> Self { + Self { + name: name.into(), + ..Self::default() + } + } + + /// An empty scene is useful for tests/importers. The regular `Default` + /// includes the editor's default sun and spot light. + pub fn empty() -> Self { + Self::empty_named(default_scene_name()) + } + + pub fn empty_named(name: impl Into) -> Self { + Self { + name: name.into(), + primitives: Vec::new(), + lights: Vec::new(), + lighting: LightingSettings::default(), + clouds: CloudSettings::default(), + editor_camera: EditorCamera::default(), + background_color: default_background_color(), + next_entity_id: first_entity_id(), + } + } + + pub fn primitives(&self) -> &[ScenePrimitive] { + &self.primitives + } + + pub fn primitives_mut(&mut self) -> &mut [ScenePrimitive] { + &mut self.primitives + } + + pub fn lights(&self) -> &[SceneLight] { + &self.lights + } + + pub fn lights_mut(&mut self) -> &mut [SceneLight] { + &mut self.lights + } + + pub fn add_primitive(&mut self, shape: SdfPrimitive) -> PrimitiveId { + let id = self.allocate_id(); + let name = format!("{} {id}", shape.kind_name()); + self.primitives.push(ScenePrimitive { + id, + name, + shape, + ..ScenePrimitive::default() + }); + id + } + + pub fn add_primitive_with( + &mut self, + name: impl Into, + shape: SdfPrimitive, + transform: Transform, + material: Material, + ) -> PrimitiveId { + let id = self.allocate_id(); + self.primitives.push(ScenePrimitive { + id, + name: name.into(), + transform, + shape, + material, + visible: true, + }); + id + } + + pub fn primitive(&self, id: PrimitiveId) -> Option<&ScenePrimitive> { + self.primitives.iter().find(|primitive| primitive.id == id) + } + + pub fn primitive_mut(&mut self, id: PrimitiveId) -> Option<&mut ScenePrimitive> { + self.primitives + .iter_mut() + .find(|primitive| primitive.id == id) + } + + pub fn remove_primitive(&mut self, id: PrimitiveId) -> Option { + let index = self + .primitives + .iter() + .position(|primitive| primitive.id == id)?; + Some(self.primitives.remove(index)) + } + + pub fn duplicate_primitive(&mut self, id: PrimitiveId) -> Option { + let mut duplicate = self.primitive(id)?.clone(); + duplicate.id = self.allocate_id(); + duplicate.name = format!("{} (копия)", duplicate.name); + duplicate.transform.translation += Vec3::splat(0.25); + let new_id = duplicate.id; + self.primitives.push(duplicate); + Some(new_id) + } + + pub fn add_light(&mut self, kind: LightKind) -> LightId { + let id = self.allocate_id(); + let name = format!("{} {id}", kind.display_name()); + let intensity = match kind { + LightKind::DirectionalSun => 5.0, + LightKind::Point { .. } => 750.0, + LightKind::Spot { .. } => 1_500.0, + }; + self.lights.push(SceneLight { + id, + name, + kind, + intensity, + ..SceneLight::default() + }); + id + } + + pub fn light(&self, id: LightId) -> Option<&SceneLight> { + self.lights.iter().find(|light| light.id == id) + } + + pub fn light_mut(&mut self, id: LightId) -> Option<&mut SceneLight> { + self.lights.iter_mut().find(|light| light.id == id) + } + + pub fn remove_light(&mut self, id: LightId) -> Option { + let index = self.lights.iter().position(|light| light.id == id)?; + Some(self.lights.remove(index)) + } + + pub fn duplicate_light(&mut self, id: LightId) -> Option { + let mut duplicate = self.light(id)?.clone(); + duplicate.id = self.allocate_id(); + duplicate.name = format!("{} (копия)", duplicate.name); + duplicate.transform.translation += Vec3::splat(0.5); + let new_id = duplicate.id; + self.lights.push(duplicate); + Some(new_id) + } + + pub fn sample(&self, world_point: Vec3) -> SceneSample { + self.sample_primitive_ids( + world_point, + self.primitives + .iter() + .filter(|primitive| primitive.visible) + .map(|primitive| primitive.id), + ) + } + + pub(crate) fn sample_primitive_ids( + &self, + world_point: Vec3, + ids: impl IntoIterator, + ) -> SceneSample { + let mut sample = SceneSample::EMPTY; + for id in ids { + let Some(primitive) = self.primitive(id) else { + continue; + }; + if !primitive.visible { + continue; + } + // The cheap conservative sphere is evaluated first. Its distance + // is a lower bound for the enclosed shape, so the expensive SDF + // can only be skipped when that lower bound cannot beat the best + // exact distance found so far. Substituting the sphere distance + // itself would create false hits on the bounding sphere. + if let Some(bounds) = primitive.bounding_sphere() { + let bound_distance = world_point.distance(bounds.center) - bounds.radius; + if bound_distance >= sample.distance { + continue; + } + } + let distance = primitive.signed_distance(world_point); + if distance.is_finite() && distance < sample.distance { + sample = SceneSample { + distance, + primitive_id: Some(id), + }; + } + } + sample + } + + pub fn ray_march(&self, ray: Ray, settings: RayMarchSettings) -> Option { + if !ray.origin.is_finite() + || !ray.direction.is_finite() + || ray.direction.length_squared() < 1.0e-12 + || settings.max_steps == 0 + || !settings.max_distance.is_finite() + || settings.max_distance <= 0.0 + { + return None; + } + + let direction = ray.direction.normalize(); + let surface_epsilon = settings.surface_epsilon.abs().max(1.0e-6); + let mut distance_travelled = 0.0; + for steps in 0..settings.max_steps { + let position = ray.origin + direction * distance_travelled; + let sample = self.sample(position); + let primitive_id = sample.primitive_id?; + if sample.distance <= surface_epsilon { + let primitive = self.primitive(primitive_id)?; + return Some(RayHit { + primitive_id, + position, + normal: primitive.normal(position, settings.normal_epsilon), + distance: distance_travelled, + steps: steps + 1, + }); + } + distance_travelled += sample.distance.max(surface_epsilon); + if distance_travelled > settings.max_distance { + break; + } + } + None + } + + pub fn validate(&self) -> Result<(), SceneValidationError> { + let mut ids = HashSet::with_capacity(self.primitives.len() + self.lights.len()); + for primitive in &self.primitives { + validate_id(&mut ids, primitive.id)?; + if !primitive.transform.is_finite() { + return Err(SceneValidationError::InvalidPrimitive { + id: primitive.id, + reason: "transform contains non-finite or zero-scale values".into(), + }); + } + primitive.shape.validate_parameters().map_err(|reason| { + SceneValidationError::InvalidPrimitive { + id: primitive.id, + reason, + } + })?; + primitive.material.validate().map_err(|reason| { + SceneValidationError::InvalidPrimitive { + id: primitive.id, + reason, + } + })?; + } + for light in &self.lights { + validate_id(&mut ids, light.id)?; + light + .validate() + .map_err(|reason| SceneValidationError::InvalidLight { + id: light.id, + reason, + })?; + } + self.lighting + .validate() + .map_err(SceneValidationError::InvalidSettings)?; + self.clouds + .validate() + .map_err(SceneValidationError::InvalidSettings)?; + self.editor_camera + .validate() + .map_err(SceneValidationError::InvalidSettings)?; + if !self.background_color.is_finite() || self.background_color.min_element() < 0.0 { + return Err(SceneValidationError::InvalidSettings( + "background color must be finite and non-negative".into(), + )); + } + if self.next_entity_id == 0 || ids.contains(&self.next_entity_id) { + return Err(SceneValidationError::InvalidAllocator); + } + Ok(()) + } + + /// Repairs only allocator metadata. Object IDs themselves are never + /// rewritten, preserving references from editor state and saved maps. + pub(crate) fn repair_allocator(&mut self) -> Result<(), SceneValidationError> { + let mut ids = HashSet::with_capacity(self.primitives.len() + self.lights.len()); + for id in self + .primitives + .iter() + .map(|primitive| primitive.id) + .chain(self.lights.iter().map(|light| light.id)) + { + validate_id(&mut ids, id)?; + } + + let max_id = ids.iter().copied().max().unwrap_or(0); + if self.next_entity_id == 0 + || self.next_entity_id <= max_id + || ids.contains(&self.next_entity_id) + { + self.next_entity_id = max_id + .checked_add(1) + .filter(|candidate| *candidate != 0 && !ids.contains(candidate)) + .or_else(|| (1..=u32::MAX).find(|candidate| !ids.contains(candidate))) + .ok_or(SceneValidationError::IdSpaceExhausted)?; + } + Ok(()) + } + + fn allocate_id(&mut self) -> EntityId { + self.repair_allocator() + .expect("scene entity ID space is exhausted or corrupt"); + let id = self.next_entity_id; + self.next_entity_id = id.checked_add(1).unwrap_or(1); + id + } +} + +#[derive(Debug, Error, Clone, PartialEq, Eq)] +pub enum SceneValidationError { + #[error("entity ID 0 is reserved")] + ReservedId, + #[error("duplicate entity ID {0}")] + DuplicateId(EntityId), + #[error("primitive {id} is invalid: {reason}")] + InvalidPrimitive { id: PrimitiveId, reason: String }, + #[error("light {id} is invalid: {reason}")] + InvalidLight { id: LightId, reason: String }, + #[error("scene settings are invalid: {0}")] + InvalidSettings(String), + #[error("scene entity ID allocator metadata is invalid")] + InvalidAllocator, + #[error("scene entity ID space is exhausted")] + IdSpaceExhausted, +} + +fn validate_id(ids: &mut HashSet, id: EntityId) -> Result<(), SceneValidationError> { + if id == 0 { + return Err(SceneValidationError::ReservedId); + } + if !ids.insert(id) { + return Err(SceneValidationError::DuplicateId(id)); + } + Ok(()) +} + +fn first_entity_id() -> EntityId { + 1 +} + +fn default_scene_name() -> String { + "Новая карта".into() +} + +fn default_background_color() -> Vec3 { + Vec3::new(0.055, 0.085, 0.14) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_scene_has_placeable_sun_and_spot() { + let scene = Scene::default(); + assert_eq!(scene.lights.len(), 2); + assert!(matches!(scene.lights[0].kind, LightKind::DirectionalSun)); + assert!(matches!(scene.lights[1].kind, LightKind::Spot { .. })); + assert_ne!(scene.lights[0].id, scene.lights[1].id); + assert!(scene.lights[0].direction().z < -0.8); + assert!(scene.lights[1].direction().z < -0.6); + scene.validate().unwrap(); + } + + #[test] + fn primitive_and_light_ids_share_one_stable_namespace() { + let mut scene = Scene::default(); + let primitive = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 }); + let light = scene.add_light(LightKind::spot_default()); + let duplicate = scene.duplicate_primitive(primitive).unwrap(); + assert_ne!(primitive, light); + assert_ne!(light, duplicate); + assert_eq!(scene.remove_primitive(primitive).unwrap().id, primitive); + assert!(scene.primitive(duplicate).is_some()); + scene.validate().unwrap(); + } + + #[test] + fn transformed_sdf_and_cpu_ray_march_hit_expected_primitive() { + let mut scene = Scene::empty(); + let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 }); + scene.primitive_mut(id).unwrap().transform.translation = Vec3::new(0.0, 0.0, -5.0); + + let ray = Ray::new(Vec3::ZERO, -Vec3::Z).unwrap(); + let hit = scene.ray_march(ray, RayMarchSettings::default()).unwrap(); + assert_eq!(hit.primitive_id, id); + assert!((hit.distance - 4.0).abs() < 0.01); + assert!(hit.normal.z > 0.99); + } + + #[test] + fn bounding_sphere_is_not_reported_as_a_surface() { + let mut scene = Scene::empty(); + let id = scene.add_primitive(SdfPrimitive::Torus { + major_radius: 1.0, + minor_radius: 0.2, + }); + scene.primitive_mut(id).unwrap().transform.translation = Vec3::new(0.0, 0.0, -5.0); + + // This ray crosses the torus bounding sphere but travels through the + // hole along its local Z axis, so it must not produce a primitive hit. + let ray = Ray::new(Vec3::new(0.0, 0.0, -2.0), -Vec3::Z).unwrap(); + assert!(scene.ray_march(ray, RayMarchSettings::default()).is_none()); + } +} diff --git a/src/scene/spatial_grid.rs b/src/scene/spatial_grid.rs new file mode 100644 index 0000000..92c341d --- /dev/null +++ b/src/scene/spatial_grid.rs @@ -0,0 +1,237 @@ +use std::collections::HashMap; + +use glam::Vec3; + +use super::{BoundingSphere, PrimitiveId, Scene, SceneSample}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct GridCell { + pub x: i32, + pub y: i32, + pub z: i32, +} + +impl GridCell { + pub const fn new(x: i32, y: i32, z: i32) -> Self { + Self { x, y, z } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct SpatialGridBuildOptions { + pub cell_size: f32, + /// Very large primitives are cheaper and safer in the global list than in + /// millions of individual hash-map entries. + pub max_cells_per_primitive: usize, +} + +impl Default for SpatialGridBuildOptions { + fn default() -> Self { + Self { + cell_size: 8.0, + max_cells_per_primitive: 32_768, + } + } +} + +/// Runtime-only uniform-grid index. Rebuild it after primitive transforms or +/// visibility change; it is deliberately not serialized with the map. +#[derive(Debug, Clone)] +pub struct SpatialGrid { + cell_size: f32, + cells: HashMap>, + global: Vec, +} + +impl SpatialGrid { + pub fn build(scene: &Scene, cell_size: f32) -> Self { + Self::with_options( + scene, + SpatialGridBuildOptions { + cell_size, + ..SpatialGridBuildOptions::default() + }, + ) + } + + pub fn with_options(scene: &Scene, options: SpatialGridBuildOptions) -> Self { + let cell_size = if options.cell_size.is_finite() && options.cell_size > 1.0e-4 { + options.cell_size + } else { + SpatialGridBuildOptions::default().cell_size + }; + let max_cells = options.max_cells_per_primitive.max(1); + let mut grid = Self { + cell_size, + cells: HashMap::new(), + global: Vec::new(), + }; + + for primitive in scene.primitives().iter().filter(|item| item.visible) { + let Some(bounds) = primitive.bounding_sphere() else { + // Plane and any future unbounded SDF must be evaluated in every + // cell. In particular, a plane must never disappear merely + // because its transform is far from the grid origin. + grid.global.push(primitive.id); + continue; + }; + if !bounds.center.is_finite() || !bounds.radius.is_finite() { + grid.global.push(primitive.id); + continue; + } + + let min = grid.cell_for_point(bounds.center - Vec3::splat(bounds.radius)); + let max = grid.cell_for_point(bounds.center + Vec3::splat(bounds.radius)); + if cell_volume(min, max) > max_cells as u64 { + grid.global.push(primitive.id); + continue; + } + + for x in min.x..=max.x { + for y in min.y..=max.y { + for z in min.z..=max.z { + let cell = GridCell::new(x, y, z); + if sphere_intersects_cell(bounds, cell, cell_size) { + grid.cells.entry(cell).or_default().push(primitive.id); + } + } + } + } + } + + for ids in grid.cells.values_mut() { + ids.sort_unstable(); + ids.dedup(); + } + grid.global.sort_unstable(); + grid.global.dedup(); + grid + } + + pub fn cell_size(&self) -> f32 { + self.cell_size + } + + pub fn cell_count(&self) -> usize { + self.cells.len() + } + + pub fn global_ids(&self) -> &[PrimitiveId] { + &self.global + } + + pub fn occupied_cells(&self) -> impl Iterator + '_ { + self.cells.iter().map(|(cell, ids)| (*cell, ids.as_slice())) + } + + pub fn cell_for_point(&self, point: Vec3) -> GridCell { + GridCell::new( + floor_to_i32(point.x / self.cell_size), + floor_to_i32(point.y / self.cell_size), + floor_to_i32(point.z / self.cell_size), + ) + } + + pub fn ids_in_cell(&self, cell: GridCell) -> &[PrimitiveId] { + self.cells.get(&cell).map(Vec::as_slice).unwrap_or(&[]) + } + + pub fn candidates_at(&self, point: Vec3) -> impl Iterator + Clone + '_ { + self.ids_in_cell(self.cell_for_point(point)) + .iter() + .chain(self.global.iter()) + .copied() + } + + pub fn candidate_ids(&self, point: Vec3) -> Vec { + self.candidates_at(point).collect() + } + + /// Samples only primitives assigned to the point's cell plus all global + /// primitives. This is intended for a grid traversal, not as a replacement + /// for `Scene::sample` at arbitrary points outside the indexed bounds. + pub fn sample_cell(&self, scene: &Scene, point: Vec3) -> SceneSample { + scene.sample_primitive_ids(point, self.candidates_at(point)) + } +} + +fn sphere_intersects_cell(sphere: BoundingSphere, cell: GridCell, cell_size: f32) -> bool { + let min = Vec3::new(cell.x as f32, cell.y as f32, cell.z as f32) * cell_size; + let max = min + Vec3::splat(cell_size); + let closest = sphere.center.clamp(min, max); + closest.distance_squared(sphere.center) <= sphere.radius * sphere.radius +} + +fn cell_volume(min: GridCell, max: GridCell) -> u64 { + let x = (i64::from(max.x) - i64::from(min.x) + 1).max(0) as u64; + let y = (i64::from(max.y) - i64::from(min.y) + 1).max(0) as u64; + let z = (i64::from(max.z) - i64::from(min.z) + 1).max(0) as u64; + x.saturating_mul(y).saturating_mul(z) +} + +fn floor_to_i32(value: f32) -> i32 { + if value.is_nan() { + 0 + } else if value <= i32::MIN as f32 { + i32::MIN + } else if value >= i32::MAX as f32 { + i32::MAX + } else { + value.floor() as i32 + } +} + +#[cfg(test)] +mod tests { + use glam::Vec3; + + use super::*; + use crate::scene::{SdfPrimitive, Transform}; + + #[test] + fn finite_objects_fill_cells_but_plane_is_always_global() { + let mut scene = Scene::empty(); + let sphere = scene.add_primitive(SdfPrimitive::Sphere { radius: 1.0 }); + let plane = scene.add_primitive(SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.0, + }); + let grid = SpatialGrid::build(&scene, 1.0); + + assert_eq!(grid.global_ids(), &[plane]); + let near = grid.candidate_ids(Vec3::ZERO); + assert!(near.contains(&sphere)); + assert!(near.contains(&plane)); + let far = grid.candidate_ids(Vec3::splat(100.0)); + assert_eq!(far, vec![plane]); + } + + #[test] + fn negative_world_coordinates_use_floor_not_truncation() { + let mut scene = Scene::empty(); + let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 0.2 }); + scene.primitive_mut(id).unwrap().transform = Transform::from_translation(Vec3::splat(-1.2)); + let grid = SpatialGrid::build(&scene, 1.0); + + assert_eq!( + grid.cell_for_point(Vec3::splat(-1.2)), + GridCell::new(-2, -2, -2) + ); + assert!(grid.candidate_ids(Vec3::splat(-1.2)).contains(&id)); + } + + #[test] + fn oversized_finite_shape_uses_global_fallback() { + let mut scene = Scene::empty(); + let id = scene.add_primitive(SdfPrimitive::Sphere { radius: 10.0 }); + let grid = SpatialGrid::with_options( + &scene, + SpatialGridBuildOptions { + cell_size: 1.0, + max_cells_per_primitive: 8, + }, + ); + assert_eq!(grid.global_ids(), &[id]); + assert_eq!(grid.cell_count(), 0); + } +} diff --git a/src/scene/transform.rs b/src/scene/transform.rs new file mode 100644 index 0000000..7307d52 --- /dev/null +++ b/src/scene/transform.rs @@ -0,0 +1,108 @@ +use glam::{Mat4, Quat, Vec3}; +use serde::{Deserialize, Serialize}; + +/// Position, orientation and scale of an SDF primitive in world space. +/// +/// Non-uniform scales are supported conservatively: distances are multiplied +/// by the smallest absolute scale component, so CPU sphere tracing never +/// oversteps the transformed surface. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)] +#[serde(default)] +pub struct Transform { + pub translation: Vec3, + pub rotation: Quat, + pub scale: Vec3, +} + +impl Default for Transform { + fn default() -> Self { + Self { + translation: Vec3::ZERO, + rotation: Quat::IDENTITY, + scale: Vec3::ONE, + } + } +} + +impl Transform { + pub const MIN_SCALE: f32 = 1.0e-6; + + pub fn from_translation(translation: Vec3) -> Self { + Self { + translation, + ..Self::default() + } + } + + pub fn matrix(self) -> Mat4 { + Mat4::from_scale_rotation_translation( + self.safe_scale(), + self.safe_rotation(), + self.translation, + ) + } + + pub fn local_to_world(self, point: Vec3) -> Vec3 { + self.translation + self.safe_rotation() * (point * self.safe_scale()) + } + + pub fn world_to_local(self, point: Vec3) -> Vec3 { + let unrotated = self.safe_rotation().conjugate() * (point - self.translation); + unrotated / self.safe_scale() + } + + pub fn minimum_abs_scale(self) -> f32 { + let scale = self.safe_scale().abs(); + scale.x.min(scale.y).min(scale.z) + } + + pub fn maximum_abs_scale(self) -> f32 { + let scale = self.safe_scale().abs(); + scale.x.max(scale.y).max(scale.z) + } + + pub fn is_finite(self) -> bool { + self.translation.is_finite() + && self.rotation.is_finite() + && self.scale.is_finite() + && self.scale.abs().min_element() >= Self::MIN_SCALE + && self.rotation.length_squared() >= Self::MIN_SCALE + } + + fn safe_rotation(self) -> Quat { + if self.rotation.is_finite() && self.rotation.length_squared() >= Self::MIN_SCALE { + self.rotation.normalize() + } else { + Quat::IDENTITY + } + } + + fn safe_scale(self) -> Vec3 { + self.scale.map(|component| { + if component.abs() >= Self::MIN_SCALE { + component + } else if component.is_sign_negative() { + -Self::MIN_SCALE + } else { + Self::MIN_SCALE + } + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn transform_round_trip() { + let transform = Transform { + translation: Vec3::new(3.0, -2.0, 1.0), + rotation: Quat::from_rotation_y(0.7), + scale: Vec3::new(2.0, 3.0, 0.5), + }; + let local = Vec3::new(0.5, -1.0, 4.0); + let restored = transform.world_to_local(transform.local_to_world(local)); + assert!((restored - local).length() < 1.0e-5); + } +} diff --git a/src/stats.rs b/src/stats.rs new file mode 100644 index 0000000..8794b35 --- /dev/null +++ b/src/stats.rs @@ -0,0 +1,76 @@ +/// Per-frame timings shown by the editor's `stat fps` / `stat unit` overlay. +/// +/// The values intentionally update every frame. GPU time is optional because +/// Vulkan adapters are allowed to omit timestamp-query support. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct FrameStats { + pub fps: f32, + pub frame_ms: f32, + pub game_ms: f32, + pub draw_ms: f32, + pub gpu_ms: Option, +} + +impl Default for FrameStats { + fn default() -> Self { + Self { + fps: 0.0, + frame_ms: 0.0, + game_ms: 0.0, + draw_ms: 0.0, + gpu_ms: None, + } + } +} + +impl FrameStats { + pub fn record_frame( + &mut self, + frame_seconds: f32, + game_seconds: f32, + draw_seconds: f32, + gpu_ms: Option, + ) { + let frame_seconds = finite_non_negative(frame_seconds); + self.frame_ms = frame_seconds * 1_000.0; + self.fps = if frame_seconds > 0.0 { + 1.0 / frame_seconds + } else { + 0.0 + }; + self.game_ms = finite_non_negative(game_seconds) * 1_000.0; + self.draw_ms = finite_non_negative(draw_seconds) * 1_000.0; + self.gpu_ms = gpu_ms.filter(|value| value.is_finite() && *value >= 0.0); + } +} + +fn finite_non_negative(value: f32) -> f32 { + if value.is_finite() { + value.max(0.0) + } else { + 0.0 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn records_unsmoothed_per_frame_values() { + let mut stats = FrameStats::default(); + stats.record_frame(0.01, 0.002, 0.003, Some(4.0)); + assert!((stats.fps - 100.0).abs() < 1.0e-4); + assert!((stats.frame_ms - 10.0).abs() < 1.0e-4); + assert!((stats.game_ms - 2.0).abs() < 1.0e-4); + assert!((stats.draw_ms - 3.0).abs() < 1.0e-4); + assert_eq!(stats.gpu_ms, Some(4.0)); + } + + #[test] + fn rejects_invalid_timings() { + let mut stats = FrameStats::default(); + stats.record_frame(f32::NAN, -1.0, f32::INFINITY, Some(f32::NAN)); + assert_eq!(stats, FrameStats::default()); + } +} diff --git a/src/ui.rs b/src/ui.rs new file mode 100644 index 0000000..be06289 --- /dev/null +++ b/src/ui.rs @@ -0,0 +1,1484 @@ +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, Vec2 as EguiVec2}; +use glam::{EulerRot, Vec2, Vec3, Vec4}; + +use crate::{ + editor::{ + Axis, GizmoHandle, Plane, SceneEditorState, SceneObjectId, TransformMode, TransformSpace, + }, + scene::{LightKind, Material, Scene, SdfPrimitive, Transform}, + stats::FrameStats, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UiAction { + NewMap, + SaveMap, + SaveMapAs, + LoadMap, + SetVsync(bool), +} + +#[derive(Debug, Clone)] +pub struct EditorUiOutput { + pub viewport: Rect, + pub viewport_hovered: bool, + pub action: Option, +} + +#[derive(Debug, Clone)] +pub struct EditorUi { + pub status: String, + pub use_grid: bool, + pub shadows: bool, + pub ambient_occlusion: bool, + pub fog: bool, + pub clouds: bool, + pub vsync: bool, + pub show_help: bool, + outliner_anchor: Option, +} + +impl Default for EditorUi { + fn default() -> Self { + Self { + status: "Готово".into(), + use_grid: true, + shadows: true, + ambient_occlusion: true, + fog: true, + clouds: true, + vsync: false, + show_help: true, + outliner_anchor: None, + } + } +} + +impl EditorUi { + pub fn show( + &mut self, + root_ui: &mut egui::Ui, + scene: &mut Scene, + editor: &mut SceneEditorState, + stats: &FrameStats, + ) -> EditorUiOutput { + let mut action = None; + let ctx = root_ui.ctx().clone(); + + egui::Panel::top("toolbar") + .exact_size(34.0) + .show(root_ui, |ui| { + ui.horizontal_centered(|ui| { + ui.menu_button("Файл", |ui| { + if ui.button("Новая карта Ctrl+N").clicked() { + action = Some(UiAction::NewMap); + ui.close(); + } + if ui.button("Открыть… Ctrl+O").clicked() { + action = Some(UiAction::LoadMap); + ui.close(); + } + ui.separator(); + if ui.button("Сохранить Ctrl+S").clicked() { + action = Some(UiAction::SaveMap); + ui.close(); + } + if ui.button("Сохранить как… Ctrl+Shift+S").clicked() { + action = Some(UiAction::SaveMapAs); + ui.close(); + } + }); + ui.separator(); + transform_mode_button(ui, editor, TransformMode::Translate, "W Перемещение"); + transform_mode_button(ui, editor, TransformMode::Rotate, "E Вращение"); + transform_mode_button(ui, editor, TransformMode::Scale, "R Масштаб"); + ui.separator(); + if ui + .selectable_label(editor.transform_space == TransformSpace::World, "Мир") + .clicked() + { + editor.transform_space = TransformSpace::World; + } + if ui + .selectable_label( + editor.transform_space == TransformSpace::Local, + "Локально", + ) + .clicked() + { + editor.transform_space = TransformSpace::Local; + } + ui.separator(); + ui.checkbox(&mut self.use_grid, "Сетка"); + ui.checkbox(&mut self.shadows, "Тени"); + ui.checkbox(&mut self.ambient_occlusion, "AO"); + ui.checkbox(&mut self.clouds, "Облака"); + let previous_vsync = self.vsync; + ui.checkbox(&mut self.vsync, "VSync"); + if self.vsync != previous_vsync { + action = Some(UiAction::SetVsync(self.vsync)); + } + ui.separator(); + ui.small(&self.status); + }); + }); + + egui::Panel::left("scene_tree") + .default_size(230.0) + .min_size(180.0) + .show(root_ui, |ui| { + ui.horizontal(|ui| { + ui.heading("Сцена"); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui + .small_button("Снять выделение") + .on_hover_text("Снять выделение со всех объектов") + .clicked() + { + editor.clear_selection(); + self.outliner_anchor = None; + } + }); + }); + ui.horizontal(|ui| { + primitive_add_menu(ui, scene, editor); + light_add_menu(ui, scene, editor); + }); + ui.separator(); + let primitive_rows: Vec<_> = scene + .primitives() + .iter() + .map(|primitive| { + let visibility = if primitive.visible { "●" } else { "○" }; + ( + SceneObjectId::Primitive(primitive.id), + format!("{visibility} {}", primitive.name), + ) + }) + .collect(); + let light_rows: Vec<_> = scene + .lights() + .iter() + .map(|light| { + let icon = match light.kind { + LightKind::DirectionalSun => "☀", + LightKind::Point { .. } => "●", + LightKind::Spot { .. } => "◉", + }; + ( + SceneObjectId::Light(light.id), + format!("{icon} {}", light.name), + ) + }) + .collect(); + let outliner_order: Vec<_> = primitive_rows + .iter() + .chain(light_rows.iter()) + .map(|(id, _)| *id) + .collect(); + + egui::ScrollArea::vertical() + .id_salt("scene_outliner_scroll") + .show_viewport(ui, |ui, visible| { + let content_top = ui.cursor().top(); + ui.label(egui::RichText::new("Примитивы").strong()); + for (id, label) in &primitive_rows { + let response = + ui.selectable_label(editor.selection.contains(*id), label); + if response.clicked() { + self.select_outliner_row(ui, scene, editor, &outliner_order, *id); + } + } + ui.add_space(8.0); + ui.label(egui::RichText::new("Источники света").strong()); + for (id, label) in &light_rows { + let response = + ui.selectable_label(editor.selection.contains(*id), label); + if response.clicked() { + self.select_outliner_row(ui, scene, editor, &outliner_order, *id); + } + } + + let used_height = (ui.cursor().top() - content_top).max(0.0); + let blank_height = (visible.height() - used_height).max(28.0); + let blank = ui.allocate_response( + EguiVec2::new(ui.available_width(), blank_height), + Sense::click(), + ); + if blank.clicked() { + editor.clear_selection(); + self.outliner_anchor = None; + } + blank.context_menu(|ui| { + if ui.button("Снять всё выделение").clicked() { + editor.clear_selection(); + self.outliner_anchor = None; + ui.close(); + } + }); + }); + }); + + egui::Panel::right("inspector") + .default_size(300.0) + .min_size(250.0) + .show(root_ui, |ui| { + ui.heading("Свойства"); + ui.separator(); + if editor.selection.len() > 1 { + ui.label(format!("Выбрано объектов: {}", editor.selection.len())); + ui.weak("Показаны свойства активного объекта"); + ui.separator(); + } + match editor.selection.active() { + Some(SceneObjectId::Primitive(id)) => { + if let Some(primitive) = scene.primitive_mut(id) { + ui.text_edit_singleline(&mut primitive.name); + ui.checkbox(&mut primitive.visible, "Видимый"); + ui.collapsing("Трансформация", |ui| { + transform_editor(ui, &mut primitive.transform, true); + }); + ui.collapsing("Форма SDF", |ui| { + shape_editor(ui, &mut primitive.shape); + }); + if matches!(&primitive.shape, SdfPrimitive::BlackHole { .. }) { + ui.weak("Горизонт событий всегда отображается абсолютно чёрным"); + } else { + ui.collapsing("Материал", |ui| { + material_editor(ui, &mut primitive.material); + }); + } + } + } + Some(SceneObjectId::Light(id)) => { + if let Some(light) = scene.light_mut(id) { + ui.text_edit_singleline(&mut light.name); + ui.checkbox(&mut light.enabled, "Включён"); + ui.collapsing("Трансформация", |ui| { + transform_editor(ui, &mut light.transform, false); + }); + color_editor(ui, "Цвет", &mut light.color); + ui.horizontal(|ui| { + ui.label("Интенсивность"); + ui.add( + egui::DragValue::new(&mut light.intensity) + .speed(0.25) + .range(0.0..=100_000.0), + ); + }); + match &mut light.kind { + LightKind::DirectionalSun => { + ui.label("Направленный солнечный свет"); + } + LightKind::Point { range } => { + ui.label("Точечный источник света"); + positive_value(ui, "Радиус действия", range, 0.1); + } + LightKind::Spot { + range, + inner_angle_radians, + outer_angle_radians, + } => { + ui.label("Прожектор"); + positive_value(ui, "Дальность", range, 0.1); + let mut inner = inner_angle_radians.to_degrees(); + let mut outer = outer_angle_radians.to_degrees(); + ui.horizontal(|ui| { + ui.label("Внутренний угол"); + ui.add(egui::DragValue::new(&mut inner).suffix("°")); + }); + ui.horizontal(|ui| { + ui.label("Внешний угол"); + ui.add(egui::DragValue::new(&mut outer).suffix("°")); + }); + outer = outer.clamp(0.1, 89.0); + inner = inner.clamp(0.0, outer); + *inner_angle_radians = inner.to_radians(); + *outer_angle_radians = outer.to_radians(); + } + } + } + } + None => { + ui.weak("Выберите объект в сцене или во вьюпорте."); + } + } + + ui.separator(); + ui.collapsing("Освещение мира", |ui| { + color_editor(ui, "Рассеянный цвет", &mut scene.lighting.ambient_color); + unit_value( + ui, + "Сила рассеянного света", + &mut scene.lighting.ambient_intensity, + ); + positive_value( + ui, + "Мягкость теней", + &mut scene.lighting.shadow_softness, + 0.1, + ); + positive_value( + ui, + "Дальность теней", + &mut scene.lighting.max_shadow_distance, + 1.0, + ); + color_editor(ui, "Фон", &mut scene.background_color); + }); + ui.collapsing("Облака", |ui| { + ui.checkbox(&mut scene.clouds.enabled, "Включены на карте"); + unit_value(ui, "Покрытие", &mut scene.clouds.coverage); + positive_value(ui, "Плотность", &mut scene.clouds.density, 0.01); + positive_value(ui, "Масштаб", &mut scene.clouds.scale, 0.001); + positive_value(ui, "Скорость ветра", &mut scene.clouds.wind_speed, 0.05); + color_editor(ui, "Цвет облаков", &mut scene.clouds.color); + }); + }); + + let central = egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(Color32::TRANSPARENT)) + .show(root_ui, |ui| { + let size = ui.available_size().max(EguiVec2::splat(1.0)); + let (rect, response) = ui.allocate_exact_size(size, Sense::click_and_drag()); + draw_viewport_helpers(ui, rect, scene, editor); + (rect, response.hovered()) + }); + let (viewport, viewport_hovered) = central.inner; + + if self.show_help { + let help_pos = viewport.left_bottom() + EguiVec2::new(10.0, -78.0); + egui::Area::new("viewport_help".into()) + .fixed_pos(help_pos) + .order(egui::Order::Foreground) + .show(&ctx, |ui| { + egui::Frame::NONE + .fill(Color32::from_black_alpha(145)) + .corner_radius(4) + .inner_margin(6) + .show(ui, |ui| { + ui.small( + "ПКМ + WASD/QE: полёт Alt+ЛКМ: орбита СКМ: панорама F: фокус", + ); + ui.small("W/E/R: перемещение/вращение/масштаб ЛКМ: выбор/гизмо"); + ui.small( + "Ctrl+ЛКМ: добавить/убрать из выбора Shift: диапазон в списке", + ); + }); + }); + } + + const STAT_WIDTH: f32 = 168.0; + let stat_pos = Pos2::new(viewport.right() - STAT_WIDTH - 8.0, viewport.top() + 8.0); + egui::Area::new("viewport_stats".into()) + .fixed_pos(stat_pos) + .order(egui::Order::Foreground) + .show(&ctx, |ui| { + ui.set_min_width(STAT_WIDTH); + ui.set_max_width(STAT_WIDTH); + ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); + egui::Frame::NONE + .fill(Color32::from_black_alpha(170)) + .corner_radius(4) + .inner_margin(6) + .show(ui, |ui| { + ui.set_min_width(STAT_WIDTH - 12.0); + egui::Grid::new("viewport_stat_unit_grid") + .num_columns(2) + .spacing(EguiVec2::new(12.0, 1.0)) + .show(ui, |ui| { + stat_row(ui, "FPS", format!("{:>7.1}", finite_or_zero(stats.fps))); + stat_row(ui, "Кадр", stat_ms(stats.frame_ms)); + stat_row(ui, "Game", stat_ms(stats.game_ms)); + stat_row(ui, "Draw", stat_ms(stats.draw_ms)); + stat_row( + ui, + "GPU", + stats + .gpu_ms + .filter(|value| value.is_finite() && *value >= 0.0) + .map(stat_ms) + .unwrap_or_else(|| " н/д".to_owned()), + ); + }); + }); + }); + + EditorUiOutput { + viewport, + viewport_hovered, + action, + } + } + + fn select_outliner_row( + &mut self, + ui: &egui::Ui, + scene: &Scene, + editor: &mut SceneEditorState, + order: &[SceneObjectId], + clicked: SceneObjectId, + ) { + let modifiers = ui.input(|input| input.modifiers); + if modifiers.shift { + let anchor = self + .outliner_anchor + .or_else(|| editor.selection.active()) + .unwrap_or(clicked); + let anchor_index = order.iter().position(|id| *id == anchor); + let clicked_index = order.iter().position(|id| *id == clicked); + if let (Some(anchor_index), Some(clicked_index)) = (anchor_index, clicked_index) { + let first = anchor_index.min(clicked_index); + let last = anchor_index.max(clicked_index); + editor.set_selection_many( + scene, + order[first..=last].iter().copied(), + Some(clicked), + ); + } else { + editor.set_selection(scene, Some(clicked)); + } + } else if modifiers.ctrl { + editor.toggle_selection(scene, clicked); + self.outliner_anchor = Some(clicked); + } else { + editor.set_selection(scene, Some(clicked)); + self.outliner_anchor = Some(clicked); + } + } +} + +fn finite_or_zero(value: f32) -> f32 { + if value.is_finite() && value >= 0.0 { + value + } else { + 0.0 + } +} + +fn stat_ms(value: f32) -> String { + format!("{:>7.2} мс", finite_or_zero(value)) +} + +fn stat_row(ui: &mut egui::Ui, label: &str, value: String) { + let text = |text: String| egui::RichText::new(text).monospace().color(Color32::WHITE); + ui.label(text(label.to_owned())); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(text(value)); + }); + ui.end_row(); +} + +fn transform_mode_button( + ui: &mut egui::Ui, + editor: &mut SceneEditorState, + mode: TransformMode, + label: &str, +) { + if ui + .selectable_label(editor.transform_mode == mode, label) + .clicked() + { + editor.transform_mode = mode; + } +} + +fn primitive_add_menu(ui: &mut egui::Ui, scene: &mut Scene, editor: &mut SceneEditorState) { + ui.menu_button("+ Примитив", |ui| { + for (name, shape) in primitive_palette() { + if ui.button(name).clicked() { + let is_black_hole = matches!(&shape, SdfPrimitive::BlackHole { .. }); + let transform = if is_black_hole { + Transform::from_translation(Vec3::new(0.0, 0.0, 2.0)) + } else { + Transform::default() + }; + let material = if is_black_hole { + Material { + albedo: Vec3::ZERO, + roughness: 1.0, + ..Material::default() + } + } else { + Material::default() + }; + let id = scene.add_primitive_with(name, shape, transform, material); + editor.set_selection(scene, Some(SceneObjectId::Primitive(id))); + ui.close(); + } + } + }); +} + +fn light_add_menu(ui: &mut egui::Ui, scene: &mut Scene, editor: &mut SceneEditorState) { + ui.menu_button("+ Свет", |ui| { + if ui.button("Солнце").clicked() { + let id = scene.add_light(LightKind::DirectionalSun); + editor.set_selection(scene, Some(SceneObjectId::Light(id))); + ui.close(); + } + if ui.button("Лампочка (точечный)").clicked() { + let id = scene.add_light(LightKind::point_default()); + editor.set_selection(scene, Some(SceneObjectId::Light(id))); + ui.close(); + } + if ui.button("Прожектор").clicked() { + let id = scene.add_light(LightKind::spot_default()); + editor.set_selection(scene, Some(SceneObjectId::Light(id))); + ui.close(); + } + }); +} + +fn primitive_palette() -> [(&'static str, SdfPrimitive); 16] { + [ + ("Сфера", SdfPrimitive::Sphere { radius: 1.0 }), + ( + "Куб", + SdfPrimitive::Box { + half_extents: Vec3::ONE, + }, + ), + ( + "Скруглённый куб", + SdfPrimitive::RoundedBox { + half_extents: Vec3::ONE, + radius: 0.2, + }, + ), + ( + "Каркас куба", + SdfPrimitive::BoxFrame { + half_extents: Vec3::ONE, + edge_thickness: 0.12, + }, + ), + ( + "Плоскость", + SdfPrimitive::Plane { + normal: Vec3::Z, + offset: 0.0, + }, + ), + ( + "Тор", + SdfPrimitive::Torus { + major_radius: 1.0, + minor_radius: 0.28, + }, + ), + ( + "Звено", + SdfPrimitive::Link { + half_length: 0.65, + major_radius: 0.8, + minor_radius: 0.18, + }, + ), + ( + "Капсула", + SdfPrimitive::Capsule { + half_height: 1.0, + radius: 0.4, + }, + ), + ( + "Цилиндр", + SdfPrimitive::Cylinder { + half_height: 1.0, + radius: 0.7, + }, + ), + ( + "Усечённый конус", + SdfPrimitive::CappedCone { + half_height: 1.0, + bottom_radius: 0.8, + top_radius: 0.2, + }, + ), + ( + "Скруглённый конус", + SdfPrimitive::RoundedCone { + half_height: 1.0, + bottom_radius: 0.8, + top_radius: 0.25, + }, + ), + ( + "Эллипсоид", + SdfPrimitive::Ellipsoid { + radii: Vec3::new(1.0, 1.4, 0.8), + }, + ), + ("Октаэдр", SdfPrimitive::Octahedron { size: 1.0 }), + ( + "Треугольная призма", + SdfPrimitive::TriangularPrism { + triangle_radius: 1.0, + half_height: 0.7, + }, + ), + ( + "Шестиугольная призма", + SdfPrimitive::HexagonalPrism { + hexagon_radius: 1.0, + half_height: 0.7, + }, + ), + ( + "Чёрная дыра", + SdfPrimitive::BlackHole { + horizon_radius: 0.75, + influence_radius: 8.0, + lens_strength: 2.2, + spin: 0.9, + }, + ), + ] +} + +fn transform_editor(ui: &mut egui::Ui, transform: &mut Transform, include_scale: bool) { + vec3_editor(ui, "Положение", &mut transform.translation, 0.05); + let (x, y, z) = transform.rotation.to_euler(EulerRot::XYZ); + let mut degrees = Vec3::new(x.to_degrees(), y.to_degrees(), z.to_degrees()); + if vec3_editor(ui, "Вращение", &mut degrees, 0.25) { + let radians = Vec3::new( + degrees.x.to_radians(), + degrees.y.to_radians(), + degrees.z.to_radians(), + ); + transform.rotation = glam::Quat::from_euler(EulerRot::XYZ, radians.x, radians.y, radians.z); + } + if include_scale && vec3_editor(ui, "Масштаб", &mut transform.scale, 0.01) { + transform.scale = transform.scale.max(Vec3::splat(0.001)); + } +} + +fn localized_shape_name(shape: &SdfPrimitive) -> &'static str { + match shape { + SdfPrimitive::Sphere { .. } => "Сфера", + SdfPrimitive::Box { .. } => "Куб", + SdfPrimitive::RoundedBox { .. } => "Скруглённый куб", + SdfPrimitive::BoxFrame { .. } => "Каркас куба", + SdfPrimitive::Plane { .. } => "Плоскость", + SdfPrimitive::Torus { .. } => "Тор", + SdfPrimitive::Link { .. } => "Звено", + SdfPrimitive::Capsule { .. } => "Капсула", + SdfPrimitive::Cylinder { .. } => "Цилиндр", + SdfPrimitive::CappedCone { .. } => "Усечённый конус", + SdfPrimitive::RoundedCone { .. } => "Скруглённый конус", + SdfPrimitive::Ellipsoid { .. } => "Эллипсоид", + SdfPrimitive::Octahedron { .. } => "Октаэдр", + SdfPrimitive::TriangularPrism { .. } => "Треугольная призма", + SdfPrimitive::HexagonalPrism { .. } => "Шестиугольная призма", + SdfPrimitive::BlackHole { .. } => "Чёрная дыра", + } +} + +fn shape_editor(ui: &mut egui::Ui, shape: &mut SdfPrimitive) { + ui.strong(localized_shape_name(shape)); + match shape { + SdfPrimitive::Sphere { radius } => positive_value(ui, "Радиус", radius, 0.02), + SdfPrimitive::Box { half_extents } => { + vec3_editor(ui, "Полуразмеры", half_extents, 0.02); + } + SdfPrimitive::RoundedBox { + half_extents, + radius, + } => { + vec3_editor(ui, "Полуразмеры", half_extents, 0.02); + positive_value(ui, "Радиус скругления", radius, 0.01); + } + SdfPrimitive::BoxFrame { + half_extents, + edge_thickness, + } => { + vec3_editor(ui, "Полуразмеры", half_extents, 0.02); + positive_value(ui, "Толщина рёбер", edge_thickness, 0.01); + } + SdfPrimitive::Plane { normal, offset } => { + if vec3_editor(ui, "Нормаль", normal, 0.01) { + *normal = normal.normalize_or(Vec3::Z); + } + scalar_value(ui, "Смещение", offset, 0.02); + } + SdfPrimitive::Torus { + major_radius, + minor_radius, + } => { + positive_value(ui, "Большой радиус", major_radius, 0.02); + positive_value(ui, "Малый радиус", minor_radius, 0.01); + } + SdfPrimitive::Link { + half_length, + major_radius, + minor_radius, + } => { + positive_value(ui, "Полудлина", half_length, 0.02); + positive_value(ui, "Большой радиус", major_radius, 0.02); + positive_value(ui, "Малый радиус", minor_radius, 0.01); + } + SdfPrimitive::Capsule { + half_height, + radius, + } + | SdfPrimitive::Cylinder { + half_height, + radius, + } => { + positive_value(ui, "Полувысота", half_height, 0.02); + positive_value(ui, "Радиус", radius, 0.02); + } + SdfPrimitive::CappedCone { + half_height, + bottom_radius, + top_radius, + } + | SdfPrimitive::RoundedCone { + half_height, + bottom_radius, + top_radius, + } => { + positive_value(ui, "Полувысота", half_height, 0.02); + positive_value(ui, "Нижний радиус", bottom_radius, 0.02); + positive_value(ui, "Верхний радиус", top_radius, 0.02); + } + SdfPrimitive::Ellipsoid { radii } => { + vec3_editor(ui, "Радиусы", radii, 0.02); + *radii = radii.max(Vec3::splat(0.001)); + } + SdfPrimitive::Octahedron { size } => positive_value(ui, "Размер", size, 0.02), + SdfPrimitive::TriangularPrism { + triangle_radius, + half_height, + } => { + positive_value(ui, "Радиус треугольника", triangle_radius, 0.02); + positive_value(ui, "Полувысота", half_height, 0.02); + } + SdfPrimitive::HexagonalPrism { + hexagon_radius, + half_height, + } => { + positive_value(ui, "Радиус шестиугольника", hexagon_radius, 0.02); + positive_value(ui, "Полувысота", half_height, 0.02); + } + SdfPrimitive::BlackHole { + horizon_radius, + influence_radius, + lens_strength, + spin, + } => { + positive_value(ui, "Радиус горизонта событий", horizon_radius, 0.01); + positive_value(ui, "Радиус влияния", influence_radius, 0.05); + *influence_radius = influence_radius.max(*horizon_radius + 0.001); + scalar_value(ui, "Сила линзирования", lens_strength, 0.02); + *lens_strength = lens_strength.clamp(0.0, 24.0); + scalar_value(ui, "Закручивание", spin, 0.01); + *spin = spin.clamp(-24.0, 24.0); + } + } +} + +fn material_editor(ui: &mut egui::Ui, material: &mut Material) { + color_editor(ui, "Альбедо", &mut material.albedo); + unit_value(ui, "Шероховатость", &mut material.roughness); + unit_value(ui, "Металличность", &mut material.metallic); + color_editor(ui, "Свечение", &mut material.emissive_color); + positive_value(ui, "Сила свечения", &mut material.emission_strength, 0.05); +} + +fn vec3_editor(ui: &mut egui::Ui, label: &str, value: &mut Vec3, speed: f64) -> bool { + let mut changed = false; + ui.label(label); + ui.horizontal(|ui| { + changed |= ui + .add(egui::DragValue::new(&mut value.x).speed(speed).prefix("X ")) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut value.y).speed(speed).prefix("Y ")) + .changed(); + changed |= ui + .add(egui::DragValue::new(&mut value.z).speed(speed).prefix("Z ")) + .changed(); + }); + changed +} + +fn color_editor(ui: &mut egui::Ui, label: &str, color: &mut Vec3) { + let mut rgb = color.clamp(Vec3::ZERO, Vec3::ONE).to_array(); + ui.horizontal(|ui| { + ui.label(label); + if ui.color_edit_button_rgb(&mut rgb).changed() { + *color = Vec3::from_array(rgb); + } + }); +} + +fn scalar_value(ui: &mut egui::Ui, label: &str, value: &mut f32, speed: f64) { + ui.horizontal(|ui| { + ui.label(label); + ui.add(egui::DragValue::new(value).speed(speed)); + }); +} + +fn positive_value(ui: &mut egui::Ui, label: &str, value: &mut f32, speed: f64) { + ui.horizontal(|ui| { + ui.label(label); + ui.add( + egui::DragValue::new(value) + .speed(speed) + .range(0.001..=100_000.0), + ); + }); +} + +fn unit_value(ui: &mut egui::Ui, label: &str, value: &mut f32) { + ui.horizontal(|ui| { + ui.label(label); + ui.add(egui::Slider::new(value, 0.0..=1.0)); + }); +} + +fn draw_viewport_helpers(ui: &egui::Ui, viewport: Rect, scene: &Scene, editor: &SceneEditorState) { + if viewport.width() <= 1.0 || viewport.height() <= 1.0 { + return; + } + let painter = ui.painter_at(viewport); + let aspect = viewport.width() / viewport.height(); + let view_projection = editor.camera.view_projection_matrix(aspect); + + for light in scene.lights() { + let Some(position) = project_point(light.position(), view_projection, viewport) else { + continue; + }; + let selected = editor.selection.contains(SceneObjectId::Light(light.id)); + let color = if selected { + Color32::WHITE + } else if !light.enabled { + Color32::from_gray(105) + } else { + Color32::from_rgb(255, 216, 96) + }; + draw_light_icon(&painter, position, light.kind, color, selected); + + if selected { + match light.kind { + LightKind::DirectionalSun => { + if let Some([from, tip]) = project_world_segment( + light.position(), + light.position() + light.direction() * 2.0, + view_projection, + viewport, + ) { + painter.arrow(from, tip - from, Stroke::new(1.3, color)); + } + } + LightKind::Point { range } => draw_wire_sphere( + &painter, + light.position(), + range, + view_projection, + viewport, + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 220, 96, 180)), + ), + LightKind::Spot { + range, + outer_angle_radians, + .. + } => draw_wire_cone( + &painter, + light.position(), + light.direction(), + range, + outer_angle_radians, + view_projection, + viewport, + Stroke::new(1.0, Color32::from_rgba_unmultiplied(255, 220, 96, 190)), + ), + } + } + } + + for primitive in scene.primitives() { + if !primitive.visible + || !editor + .selection + .contains(SceneObjectId::Primitive(primitive.id)) + { + continue; + } + if let SdfPrimitive::BlackHole { + influence_radius, .. + } = &primitive.shape + { + let world_radius = + influence_radius.abs() * primitive.transform.scale.abs().max_element().max(0.001); + draw_wire_sphere( + &painter, + primitive.transform.translation, + world_radius, + view_projection, + viewport, + Stroke::new(1.2, Color32::from_rgba_unmultiplied(174, 96, 255, 205)), + ); + } + } + + let Some(origin_world) = editor.selection_pivot(scene) else { + return; + }; + let Some(origin) = project_point(origin_world, view_projection, viewport) else { + return; + }; + let Some(world_scale) = editor.gizmo_world_scale(scene, viewport.height()) else { + return; + }; + let axes = [Axis::X, Axis::Y, Axis::Z]; + let directions = axes.map(|axis| editor.gizmo_axis_direction(scene, axis)); + let endpoints = directions.map(|direction| { + direction.and_then(|direction| { + project_point( + origin_world + direction * world_scale, + view_projection, + viewport, + ) + }) + }); + + match editor.transform_mode { + TransformMode::Translate => draw_translate_gizmo( + &painter, + origin, + endpoints, + editor.gizmo_config, + editor.gizmo_hovered(), + editor.gizmo_active(), + ), + TransformMode::Rotate => draw_rotate_gizmo( + &painter, + origin_world, + directions, + world_scale, + view_projection, + viewport, + editor.gizmo_config, + editor.gizmo_hovered(), + editor.gizmo_active(), + ), + TransformMode::Scale => draw_scale_gizmo( + &painter, + origin, + endpoints, + editor.gizmo_hovered(), + editor.gizmo_active(), + ), + } +} + +fn draw_light_icon( + painter: &egui::Painter, + position: Pos2, + kind: LightKind, + color: Color32, + selected: bool, +) { + let radius = if selected { 7.0 } else { 5.0 }; + painter.circle_stroke(position, radius, Stroke::new(1.5, color)); + match kind { + LightKind::DirectionalSun => { + for index in 0..8 { + let angle = index as f32 * std::f32::consts::TAU / 8.0; + let direction = EguiVec2::angled(angle); + painter.line_segment( + [ + position + direction * (radius + 2.0), + position + direction * (radius + 5.0), + ], + Stroke::new(1.2, color), + ); + } + } + LightKind::Point { .. } => { + painter.line_segment( + [ + position - EguiVec2::X * radius, + position + EguiVec2::X * radius, + ], + Stroke::new(1.0, color), + ); + painter.line_segment( + [ + position - EguiVec2::Y * radius, + position + EguiVec2::Y * radius, + ], + Stroke::new(1.0, color), + ); + } + LightKind::Spot { .. } => { + painter.circle_filled(position, 2.0, color); + painter.line_segment( + [ + position + egui::vec2(-radius, radius), + position + egui::vec2(radius, radius), + ], + Stroke::new(1.0, color), + ); + } + } +} + +fn draw_translate_gizmo( + painter: &egui::Painter, + origin: Pos2, + endpoints: [Option; 3], + config: crate::editor::GizmoConfig, + hovered: Option, + active: Option, +) { + for plane in [Plane::XY, Plane::XZ, Plane::YZ] { + let (first, second) = plane_axis_indices(plane); + let (Some(first_end), Some(second_end)) = (endpoints[first], endpoints[second]) else { + continue; + }; + let Some(first_direction) = normalized_screen_direction(origin, first_end) else { + continue; + }; + let Some(second_direction) = normalized_screen_direction(origin, second_end) else { + continue; + }; + let inner = config.plane_inner_pixels; + let outer = config.plane_outer_pixels; + let quad = vec![ + origin + first_direction * inner + second_direction * inner, + origin + first_direction * outer + second_direction * inner, + origin + first_direction * outer + second_direction * outer, + origin + first_direction * inner + second_direction * outer, + ]; + let handle = GizmoHandle::TranslatePlane(plane); + let color = gizmo_color(plane_color(plane), handle, hovered, active); + painter.add(egui::Shape::convex_polygon( + quad, + color.gamma_multiply(0.28), + Stroke::new(gizmo_stroke_width(handle, hovered, active), color), + )); + } + + for (index, axis) in [Axis::X, Axis::Y, Axis::Z].into_iter().enumerate() { + let Some(endpoint) = endpoints[index] else { + continue; + }; + let handle = GizmoHandle::TranslateAxis(axis); + let color = gizmo_color(axis_color(axis), handle, hovered, active); + painter.line_segment( + [origin, endpoint], + Stroke::new(gizmo_stroke_width(handle, hovered, active), color), + ); + draw_arrow_head(painter, origin, endpoint, color); + draw_axis_label(painter, endpoint, axis, color); + } +} + +fn draw_scale_gizmo( + painter: &egui::Painter, + origin: Pos2, + endpoints: [Option; 3], + hovered: Option, + active: Option, +) { + for (index, axis) in [Axis::X, Axis::Y, Axis::Z].into_iter().enumerate() { + let Some(endpoint) = endpoints[index] else { + continue; + }; + let handle = GizmoHandle::ScaleAxis(axis); + let color = gizmo_color(axis_color(axis), handle, hovered, active); + painter.line_segment( + [origin, endpoint], + Stroke::new(gizmo_stroke_width(handle, hovered, active), color), + ); + painter.rect_filled( + Rect::from_center_size(endpoint, EguiVec2::splat(8.0)), + 1.0, + color, + ); + draw_axis_label(painter, endpoint, axis, color); + } + let uniform = GizmoHandle::ScaleUniform; + let color = gizmo_color(Color32::from_gray(215), uniform, hovered, active); + painter.rect_filled( + Rect::from_center_size(origin, EguiVec2::splat(12.0)), + 1.5, + color, + ); + painter.rect_stroke( + Rect::from_center_size(origin, EguiVec2::splat(12.0)), + 1.5, + Stroke::new(gizmo_stroke_width(uniform, hovered, active), Color32::BLACK), + egui::StrokeKind::Middle, + ); +} + +#[allow(clippy::too_many_arguments)] +fn draw_rotate_gizmo( + painter: &egui::Painter, + origin_world: Vec3, + directions: [Option; 3], + world_scale: f32, + view_projection: glam::Mat4, + viewport: Rect, + config: crate::editor::GizmoConfig, + hovered: Option, + active: Option, +) { + let [Some(x), Some(y), Some(z)] = directions.map(|direction| direction.map(Vec3::normalize)) + else { + return; + }; + let ring_radius = world_scale * config.ring_radius_pixels / config.axis_length_pixels.max(1.0); + let rings = [(Axis::X, y, z), (Axis::Y, z, x), (Axis::Z, x, y)]; + for (axis, first, second) in rings { + let handle = GizmoHandle::RotateAxis(axis); + let color = gizmo_color(axis_color(axis), handle, hovered, active); + draw_world_circle( + painter, + origin_world, + first, + second, + ring_radius, + view_projection, + viewport, + Stroke::new(gizmo_stroke_width(handle, hovered, active), color), + ); + } +} + +fn draw_arrow_head(painter: &egui::Painter, origin: Pos2, endpoint: Pos2, color: Color32) { + let Some(direction) = normalized_screen_direction(origin, endpoint) else { + return; + }; + let perpendicular = egui::vec2(-direction.y, direction.x); + let base = endpoint - direction * 10.0; + painter.add(egui::Shape::convex_polygon( + vec![ + endpoint, + base + perpendicular * 4.5, + base - perpendicular * 4.5, + ], + color, + Stroke::NONE, + )); +} + +fn draw_axis_label(painter: &egui::Painter, endpoint: Pos2, axis: Axis, color: Color32) { + let label = match axis { + Axis::X => "X", + Axis::Y => "Y", + Axis::Z => "Z", + Axis::View => return, + }; + painter.text( + endpoint + egui::vec2(0.0, -12.0), + Align2::CENTER_CENTER, + label, + egui::FontId::monospace(11.0), + color, + ); +} + +fn axis_color(axis: Axis) -> Color32 { + match axis { + Axis::X => Color32::from_rgb(235, 70, 70), + Axis::Y => Color32::from_rgb(90, 220, 110), + Axis::Z => Color32::from_rgb(80, 135, 245), + Axis::View => Color32::WHITE, + } +} + +fn plane_color(plane: Plane) -> Color32 { + match plane { + Plane::XY => Color32::from_rgb(226, 197, 74), + Plane::XZ => Color32::from_rgb(205, 82, 205), + Plane::YZ => Color32::from_rgb(66, 205, 195), + } +} + +fn plane_axis_indices(plane: Plane) -> (usize, usize) { + match plane { + Plane::XY => (0, 1), + Plane::XZ => (0, 2), + Plane::YZ => (1, 2), + } +} + +fn gizmo_color( + base: Color32, + handle: GizmoHandle, + hovered: Option, + active: Option, +) -> Color32 { + if active == Some(handle) { + Color32::from_rgb(255, 182, 32) + } else if hovered == Some(handle) { + Color32::WHITE + } else { + base + } +} + +fn gizmo_stroke_width( + handle: GizmoHandle, + hovered: Option, + active: Option, +) -> f32 { + if active == Some(handle) { + 4.0 + } else if hovered == Some(handle) { + 3.0 + } else { + 2.0 + } +} + +fn normalized_screen_direction(origin: Pos2, endpoint: Pos2) -> Option { + let direction = endpoint - origin; + (direction.length_sq() >= 4.0).then(|| direction / direction.length()) +} + +fn draw_wire_sphere( + painter: &egui::Painter, + center: Vec3, + radius: f32, + view_projection: glam::Mat4, + viewport: Rect, + stroke: Stroke, +) { + let radius = radius.abs().max(0.001); + for (first, second) in [(Vec3::X, Vec3::Y), (Vec3::X, Vec3::Z), (Vec3::Y, Vec3::Z)] { + draw_world_circle( + painter, + center, + first, + second, + radius, + view_projection, + viewport, + stroke, + ); + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_wire_cone( + painter: &egui::Painter, + apex: Vec3, + direction: Vec3, + range: f32, + outer_angle: f32, + view_projection: glam::Mat4, + viewport: Rect, + stroke: Stroke, +) { + let direction = direction.normalize_or(-Vec3::Z); + let range = range.abs().max(0.001); + let angle = outer_angle.clamp(0.001, 89.0_f32.to_radians()); + let helper = if direction.dot(Vec3::Y).abs() < 0.95 { + Vec3::Y + } else { + Vec3::X + }; + let first = direction.cross(helper).normalize_or(Vec3::X); + let second = direction.cross(first).normalize_or(Vec3::Y); + let base_center = apex + direction * range; + let base_radius = range * angle.tan(); + draw_world_circle( + painter, + base_center, + first, + second, + base_radius, + view_projection, + viewport, + stroke, + ); + for angle in [ + 0.0, + std::f32::consts::FRAC_PI_2, + std::f32::consts::PI, + 3.0 * std::f32::consts::FRAC_PI_2, + ] { + let edge = base_center + (first * angle.cos() + second * angle.sin()) * base_radius; + if let Some(segment) = project_world_segment(apex, edge, view_projection, viewport) { + painter.line_segment(segment, stroke); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_world_circle( + painter: &egui::Painter, + center: Vec3, + first: Vec3, + second: Vec3, + radius: f32, + view_projection: glam::Mat4, + viewport: Rect, + stroke: Stroke, +) { + const SEGMENTS: usize = 72; + let mut previous = None; + for segment in 0..=SEGMENTS { + let angle = segment as f32 * std::f32::consts::TAU / SEGMENTS as f32; + let world = center + (first * angle.cos() + second * angle.sin()) * radius; + if let Some(from) = previous + && let Some(projected) = project_world_segment(from, world, view_projection, viewport) + { + painter.line_segment(projected, stroke); + } + previous = Some(world); + } +} + +/// Clip a world-space helper segment against the complete WebGPU frustum before +/// perspective division. This avoids the very long lines that otherwise appear +/// when a light-volume circle or cone crosses the camera/near plane. +fn project_world_segment( + from: Vec3, + to: Vec3, + view_projection: glam::Mat4, + viewport: Rect, +) -> Option<[Pos2; 2]> { + let from_clip = view_projection * from.extend(1.0); + let to_clip = view_projection * to.extend(1.0); + let [from_clip, to_clip] = clip_segment_to_webgpu_frustum(from_clip, to_clip)?; + Some([ + clip_to_viewport(from_clip, viewport)?, + clip_to_viewport(to_clip, viewport)?, + ]) +} + +fn clip_segment_to_webgpu_frustum(from: Vec4, to: Vec4) -> Option<[Vec4; 2]> { + if !from.is_finite() || !to.is_finite() { + return None; + } + + // Homogeneous WebGPU clip volume: -w <= x,y <= w and 0 <= z <= w. + let from_planes = [ + from.x + from.w, + from.w - from.x, + from.y + from.w, + from.w - from.y, + from.z, + from.w - from.z, + ]; + let to_planes = [ + to.x + to.w, + to.w - to.x, + to.y + to.w, + to.w - to.y, + to.z, + to.w - to.z, + ]; + let mut start = 0.0_f32; + let mut end = 1.0_f32; + for (from_distance, to_distance) in from_planes.into_iter().zip(to_planes) { + if from_distance < 0.0 && to_distance < 0.0 { + return None; + } + if (from_distance < 0.0) != (to_distance < 0.0) { + let crossing = from_distance / (from_distance - to_distance); + if from_distance < 0.0 { + start = start.max(crossing); + } else { + end = end.min(crossing); + } + } + } + (start <= end).then(|| [from.lerp(to, start), from.lerp(to, end)]) +} + +fn clip_to_viewport(clip: Vec4, viewport: Rect) -> Option { + if !clip.is_finite() || clip.w <= 1.0e-6 { + return None; + } + let ndc = clip.truncate().truncate() / clip.w; + if !ndc.is_finite() { + return None; + } + Some(Pos2::new( + viewport.left() + (ndc.x * 0.5 + 0.5) * viewport.width(), + viewport.top() + (0.5 - ndc.y * 0.5) * viewport.height(), + )) +} + +fn project_point(world: Vec3, view_projection: glam::Mat4, viewport: Rect) -> Option { + let clip: Vec4 = view_projection * world.extend(1.0); + if !clip.is_finite() + || clip.w <= 0.001 + || clip.z < 0.0 + || clip.z > clip.w + || clip.x.abs() > clip.w * 1.5 + || clip.y.abs() > clip.w * 1.5 + { + return None; + } + let ndc: Vec2 = clip.truncate().truncate() / clip.w; + if !ndc.is_finite() { + return None; + } + Some(Pos2::new( + viewport.left() + (ndc.x * 0.5 + 0.5) * viewport.width(), + viewport.top() + (0.5 - ndc.y * 0.5) * viewport.height(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn helper_segment_is_clipped_to_viewport_edges() { + let viewport = Rect::from_min_max(Pos2::ZERO, Pos2::new(100.0, 80.0)); + let [from, to] = project_world_segment( + Vec3::new(-2.0, 0.0, 0.5), + Vec3::new(2.0, 0.0, 0.5), + glam::Mat4::IDENTITY, + viewport, + ) + .expect("the segment crosses the visible frustum"); + assert!((from.x - viewport.left()).abs() < 1.0e-5); + assert!((to.x - viewport.right()).abs() < 1.0e-5); + assert!((from.y - viewport.center().y).abs() < 1.0e-5); + assert!((to.y - viewport.center().y).abs() < 1.0e-5); + } + + #[test] + fn helper_segment_behind_near_plane_is_rejected() { + assert!( + clip_segment_to_webgpu_frustum( + Vec4::new(0.0, 0.0, -2.0, 1.0), + Vec4::new(0.0, 0.0, -1.0, 1.0), + ) + .is_none() + ); + } + + #[test] + fn helper_segment_crossing_near_plane_starts_on_near_plane() { + let [from, to] = clip_segment_to_webgpu_frustum( + Vec4::new(0.0, 0.0, -1.0, 1.0), + Vec4::new(0.0, 0.0, 0.5, 1.0), + ) + .expect("the visible portion must survive clipping"); + assert!(from.z.abs() < 1.0e-5); + assert!((to.z - 0.5).abs() < 1.0e-5); + } + + #[test] + fn helper_point_behind_near_plane_is_not_projected() { + let viewport = Rect::from_min_max(Pos2::ZERO, Pos2::new(100.0, 80.0)); + assert!( + project_point(Vec3::new(0.0, 0.0, -0.1), glam::Mat4::IDENTITY, viewport,).is_none() + ); + assert!(project_point(Vec3::new(0.0, 0.0, 0.5), glam::Mat4::IDENTITY, viewport,).is_some()); + } +}