commit f3f1b69211d442bbf4ca1452813b090edabe0ad4
parent f4abb1b87bb7437070b62052245a9da0811b21fb
Author: William Casarin <jb55@jb55.com>
Date: Mon, 23 Feb 2026 13:13:24 -0800
vendor: add renderbud 3D renderer crate
Vendor the renderbud wgpu-based 3D renderer as a workspace crate.
winit is kept as a dev-dependency only, with the Renderbud convenience
wrapper moved into the example.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Diffstat:
24 files changed, 7753 insertions(+), 2 deletions(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -6232,6 +6232,7 @@ dependencies = [
"gltf",
"half",
"image",
+ "pollster",
"rayon",
"wgpu",
"winit 0.30.11",
diff --git a/Cargo.toml b/Cargo.toml
@@ -79,12 +79,13 @@ notedeck_dave = { path = "crates/notedeck_dave" }
notedeck_messages = { path = "crates/notedeck_messages" }
notedeck_notebook = { path = "crates/notedeck_notebook" }
notedeck_nostrverse = { path = "crates/notedeck_nostrverse" }
-renderbud = { path = "../github/jb55/renderbud", features = ["egui"] }
+renderbud = { path = "crates/renderbud", features = ["egui"] }
notedeck_ui = { path = "crates/notedeck_ui" }
notedeck_wasm = { path = "crates/notedeck_wasm" }
tokenator = { path = "crates/tokenator" }
md-stream = { path = "crates/md-stream" }
once_cell = "1.19.0"
+bytemuck = "1.22.0"
robius-open = "0.1"
poll-promise = { version = "0.3.0", features = ["tokio"] }
puffin = { git = "https://github.com/jb55/puffin", package = "puffin", rev = "c6a6242adaf90b6292c0f462d2acd34d96d224d2" }
diff --git a/crates/notedeck_dave/Cargo.toml b/crates/notedeck_dave/Cargo.toml
@@ -23,7 +23,7 @@ hex = { workspace = true }
chrono = { workspace = true }
rand = { workspace = true }
uuid = { version = "1", features = ["v4"] }
-bytemuck = "1.22.0"
+bytemuck = {workspace = true }
futures = "0.3.31"
dashmap = "6"
#reqwest = "0.12.15"
diff --git a/crates/renderbud/.rustfmt.toml b/crates/renderbud/.rustfmt.toml
@@ -0,0 +1 @@
+edition = "2018"
diff --git a/crates/renderbud/COPYING b/crates/renderbud/COPYING
@@ -0,0 +1 @@
+MIT
diff --git a/crates/renderbud/Cargo.lock b/crates/renderbud/Cargo.lock
@@ -0,0 +1,2743 @@
+# 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 = "adler2"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
+
+[[package]]
+name = "ahash"
+version = "0.8.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
+dependencies = [
+ "cfg-if",
+ "getrandom",
+ "once_cell",
+ "version_check",
+ "zerocopy",
+]
+
+[[package]]
+name = "android-activity"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ef6978589202a00cd7e118380c448a08b6ed394c3a8df3a430d0898e3a42d046"
+dependencies = [
+ "android-properties",
+ "bitflags 2.10.0",
+ "cc",
+ "cesu8",
+ "jni",
+ "jni-sys",
+ "libc",
+ "log",
+ "ndk",
+ "ndk-context",
+ "ndk-sys 0.6.0+11769913",
+ "num_enum",
+ "thiserror 1.0.69",
+]
+
+[[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 = "arrayref"
+version = "0.3.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb"
+
+[[package]]
+name = "arrayvec"
+version = "0.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50"
+
+[[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.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
+
+[[package]]
+name = "base64"
+version = "0.13.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8"
+
+[[package]]
+name = "bit-set"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3"
+dependencies = [
+ "bit-vec",
+]
+
+[[package]]
+name = "bit-vec"
+version = "0.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
+
+[[package]]
+name = "bitflags"
+version = "1.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
+
+[[package]]
+name = "bitflags"
+version = "2.10.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "812e12b5285cc515a9c72a5c1d3b6d46a19dac5acfef5265968c166106e31dd3"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "block"
+version = "0.1.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a"
+
+[[package]]
+name = "block2"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c132eebf10f5cad5289222520a4a058514204aed6d791f1cf4fe8088b82d15f"
+dependencies = [
+ "objc2",
+]
+
+[[package]]
+name = "bumpalo"
+version = "3.19.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5dd9dc738b7a8311c7ade152424974d8115f2cdad61e8dab8dac9f2362298510"
+
+[[package]]
+name = "bytemuck"
+version = "1.24.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fbdf580320f38b612e485521afda1ee26d10cc9884efaaa750d383e13e3c5f4"
+dependencies = [
+ "bytemuck_derive",
+]
+
+[[package]]
+name = "bytemuck_derive"
+version = "1.10.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "byteorder"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
+
+[[package]]
+name = "byteorder-lite"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
+
+[[package]]
+name = "bytes"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b35204fbdc0b3f4446b89fc1ac2cf84a8a68971995d0bf2e925ec7cd960f9cb3"
+
+[[package]]
+name = "calloop"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec"
+dependencies = [
+ "bitflags 2.10.0",
+ "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.2.52"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3"
+dependencies = [
+ "find-msvc-tools",
+ "jobserver",
+ "libc",
+ "shlex",
+]
+
+[[package]]
+name = "cesu8"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
+
+[[package]]
+name = "codespan-reporting"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e"
+dependencies = [
+ "termcolor",
+ "unicode-width",
+]
+
+[[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 = "crc32fast"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
+dependencies = [
+ "cfg-if",
+]
+
+[[package]]
+name = "crossbeam-deque"
+version = "0.8.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51"
+dependencies = [
+ "crossbeam-epoch",
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-epoch"
+version = "0.9.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e"
+dependencies = [
+ "crossbeam-utils",
+]
+
+[[package]]
+name = "crossbeam-utils"
+version = "0.8.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
+
+[[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 = "dispatch"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
+
+[[package]]
+name = "dlib"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "330c60081dcc4c72131f8eb70510f1ac07223e5d4163db481a04a0befcffa412"
+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.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bc4feb366740ded31a004a0e4452fbf84e80ef432ecf8314c485210229672fd1"
+dependencies = [
+ "bytemuck",
+ "emath",
+]
+
+[[package]]
+name = "egui"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "25dd34cec49ab55d85ebf70139cb1ccd29c977ef6b6ba4fe85489d6877ee9ef3"
+dependencies = [
+ "ahash",
+ "bitflags 2.10.0",
+ "emath",
+ "epaint",
+ "nohash-hasher",
+ "profiling",
+]
+
+[[package]]
+name = "egui-wgpu"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d319dfef570f699b6e9114e235e862a2ddcf75f0d1a061de9e1328d92146d820"
+dependencies = [
+ "ahash",
+ "bytemuck",
+ "document-features",
+ "egui",
+ "epaint",
+ "log",
+ "profiling",
+ "thiserror 1.0.69",
+ "type-map",
+ "web-time",
+ "wgpu",
+]
+
+[[package]]
+name = "either"
+version = "1.15.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
+
+[[package]]
+name = "emath"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9e4cadcff7a5353ba72b7fea76bf2122b5ebdbc68e8155aa56dfdea90083fe1b"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "epaint"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "41fcc0f5a7c613afd2dee5e4b30c3e6acafb8ad6f0edb06068811f708a67c562"
+dependencies = [
+ "ab_glyph",
+ "ahash",
+ "bytemuck",
+ "ecolor",
+ "emath",
+ "epaint_default_fonts",
+ "nohash-hasher",
+ "parking_lot",
+ "profiling",
+]
+
+[[package]]
+name = "epaint_default_fonts"
+version = "0.31.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fc7e7a64c02cf7a5b51e745a9e45f60660a286f151c238b9d397b3e923f5082f"
+
+[[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 = "fdeflate"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
+dependencies = [
+ "simd-adler32",
+]
+
+[[package]]
+name = "find-msvc-tools"
+version = "0.1.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41"
+
+[[package]]
+name = "flate2"
+version = "1.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b375d6465b98090a5f25b1c7703f3859783755aa9a80433b36e0379a3ec2f369"
+dependencies = [
+ "crc32fast",
+ "miniz_oxide",
+]
+
+[[package]]
+name = "foldhash"
+version = "0.1.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+
+[[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.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e"
+
+[[package]]
+name = "futures-task"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988"
+
+[[package]]
+name = "futures-util"
+version = "0.3.31"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81"
+dependencies = [
+ "futures-core",
+ "futures-task",
+ "pin-project-lite",
+ "pin-utils",
+ "slab",
+]
+
+[[package]]
+name = "gethostname"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8"
+dependencies = [
+ "rustix 1.1.3",
+ "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",
+ "wasip2",
+]
+
+[[package]]
+name = "gl_generator"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d"
+dependencies = [
+ "khronos_api",
+ "log",
+ "xml-rs",
+]
+
+[[package]]
+name = "glam"
+version = "0.30.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9"
+dependencies = [
+ "bytemuck",
+]
+
+[[package]]
+name = "glow"
+version = "0.16.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c5e5ea60d70410161c8bf5da3fdfeaa1c72ed2c15f8bbb9d19fe3a4fad085f08"
+dependencies = [
+ "js-sys",
+ "slotmap",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "gltf"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e3ce1918195723ce6ac74e80542c5a96a40c2b26162c1957a5cd70799b8cacf7"
+dependencies = [
+ "base64",
+ "byteorder",
+ "gltf-json",
+ "image",
+ "lazy_static",
+ "serde_json",
+ "urlencoding",
+]
+
+[[package]]
+name = "gltf-derive"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "14070e711538afba5d6c807edb74bcb84e5dbb9211a3bf5dea0dfab5b24f4c51"
+dependencies = [
+ "inflections",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "gltf-json"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6176f9d60a7eab0a877e8e96548605dedbde9190a7ae1e80bbcc1c9af03ab14"
+dependencies = [
+ "gltf-derive",
+ "serde",
+ "serde_derive",
+ "serde_json",
+]
+
+[[package]]
+name = "glutin_wgl_sys"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c4ee00b289aba7a9e5306d57c2d05499b2e5dc427f84ac708bd2c090212cf3e"
+dependencies = [
+ "gl_generator",
+]
+
+[[package]]
+name = "gpu-alloc"
+version = "0.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171"
+dependencies = [
+ "bitflags 2.10.0",
+ "gpu-alloc-types",
+]
+
+[[package]]
+name = "gpu-alloc-types"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "gpu-allocator"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c151a2a5ef800297b4e79efa4f4bec035c5f51d5ae587287c9b952bdf734cacd"
+dependencies = [
+ "log",
+ "presser",
+ "thiserror 1.0.69",
+ "windows",
+]
+
+[[package]]
+name = "gpu-descriptor"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca"
+dependencies = [
+ "bitflags 2.10.0",
+ "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.10.0",
+]
+
+[[package]]
+name = "half"
+version = "2.7.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
+dependencies = [
+ "cfg-if",
+ "crunchy",
+ "zerocopy",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.15.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
+dependencies = [
+ "foldhash",
+]
+
+[[package]]
+name = "hashbrown"
+version = "0.16.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+
+[[package]]
+name = "heck"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
+
+[[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 = "image"
+version = "0.25.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a"
+dependencies = [
+ "bytemuck",
+ "byteorder-lite",
+ "moxcms",
+ "num-traits",
+ "png",
+ "zune-core",
+ "zune-jpeg",
+]
+
+[[package]]
+name = "indexmap"
+version = "2.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017"
+dependencies = [
+ "equivalent",
+ "hashbrown 0.16.1",
+]
+
+[[package]]
+name = "inflections"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a"
+
+[[package]]
+name = "itoa"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
+
+[[package]]
+name = "jni"
+version = "0.21.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
+dependencies = [
+ "cesu8",
+ "cfg-if",
+ "combine",
+ "jni-sys",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+ "windows-sys 0.45.0",
+]
+
+[[package]]
+name = "jni-sys"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
+
+[[package]]
+name = "jobserver"
+version = "0.1.34"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33"
+dependencies = [
+ "getrandom",
+ "libc",
+]
+
+[[package]]
+name = "js-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c942ebf8e95485ca0d52d97da7c5a2c387d0e7f0ba4c35e93bfcaee045955b3"
+dependencies = [
+ "once_cell",
+ "wasm-bindgen",
+]
+
+[[package]]
+name = "khronos-egl"
+version = "6.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76"
+dependencies = [
+ "libc",
+ "libloading",
+ "pkg-config",
+]
+
+[[package]]
+name = "khronos_api"
+version = "3.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc"
+
+[[package]]
+name = "lazy_static"
+version = "1.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+
+[[package]]
+name = "libc"
+version = "0.2.180"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
+
+[[package]]
+name = "libloading"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55"
+dependencies = [
+ "cfg-if",
+ "windows-link",
+]
+
+[[package]]
+name = "libredox"
+version = "0.1.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
+dependencies = [
+ "bitflags 2.10.0",
+ "libc",
+ "redox_syscall 0.7.0",
+]
+
+[[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.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
+
+[[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.29"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
+
+[[package]]
+name = "malloc_buf"
+version = "0.0.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "memchr"
+version = "2.7.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
+
+[[package]]
+name = "memmap2"
+version = "0.9.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "744133e4a0e0a658e1374cf3bf8e415c4052a15a111acd372764c55b4177d490"
+dependencies = [
+ "libc",
+]
+
+[[package]]
+name = "metal"
+version = "0.31.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f569fb946490b5743ad69813cb19629130ce9374034abe31614a36402d18f99e"
+dependencies = [
+ "bitflags 2.10.0",
+ "block",
+ "core-graphics-types",
+ "foreign-types",
+ "log",
+ "objc",
+ "paste",
+]
+
+[[package]]
+name = "miniz_oxide"
+version = "0.8.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
+dependencies = [
+ "adler2",
+ "simd-adler32",
+]
+
+[[package]]
+name = "moxcms"
+version = "0.7.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97"
+dependencies = [
+ "num-traits",
+ "pxfm",
+]
+
+[[package]]
+name = "naga"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e380993072e52eef724eddfcde0ed013b0c023c3f0417336ed041aa9f076994e"
+dependencies = [
+ "arrayvec",
+ "bit-set",
+ "bitflags 2.10.0",
+ "cfg_aliases",
+ "codespan-reporting",
+ "hexf-parse",
+ "indexmap",
+ "log",
+ "rustc-hash 1.1.0",
+ "spirv",
+ "strum",
+ "termcolor",
+ "thiserror 2.0.17",
+ "unicode-xid",
+]
+
+[[package]]
+name = "ndk"
+version = "0.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
+dependencies = [
+ "bitflags 2.10.0",
+ "jni-sys",
+ "log",
+ "ndk-sys 0.6.0+11769913",
+ "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.5.0+25.2.9519653"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691"
+dependencies = [
+ "jni-sys",
+]
+
+[[package]]
+name = "ndk-sys"
+version = "0.6.0+11769913"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
+dependencies = [
+ "jni-sys",
+]
+
+[[package]]
+name = "nohash-hasher"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
+
+[[package]]
+name = "num-traits"
+version = "0.2.19"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
+dependencies = [
+ "autocfg",
+]
+
+[[package]]
+name = "num_enum"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
+dependencies = [
+ "num_enum_derive",
+ "rustversion",
+]
+
+[[package]]
+name = "num_enum_derive"
+version = "0.7.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
+dependencies = [
+ "proc-macro-crate",
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "objc"
+version = "0.2.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1"
+dependencies = [
+ "malloc_buf",
+]
+
+[[package]]
+name = "objc-sys"
+version = "0.3.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310"
+
+[[package]]
+name = "objc2"
+version = "0.5.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46a785d4eeff09c14c487497c162e92766fbb3e4059a71840cecc03d9a50b804"
+dependencies = [
+ "objc-sys",
+ "objc2-encode",
+]
+
+[[package]]
+name = "objc2-app-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "libc",
+ "objc2",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-foundation",
+ "objc2-quartz-core",
+]
+
+[[package]]
+name = "objc2-cloud-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-core-location",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-contacts"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-data"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-core-image"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "55260963a527c99f1819c4f8e3b47fe04f9650694ef348ffd2227e8196d34c80"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-core-location"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-contacts",
+ "objc2-foundation",
+]
+
+[[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.10.0",
+ "block2",
+ "dispatch",
+ "libc",
+ "objc2",
+]
+
+[[package]]
+name = "objc2-link-presentation"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-metal"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-quartz-core"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-foundation",
+ "objc2-metal",
+]
+
+[[package]]
+name = "objc2-symbols"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc"
+dependencies = [
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-ui-kit"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-cloud-kit",
+ "objc2-core-data",
+ "objc2-core-image",
+ "objc2-core-location",
+ "objc2-foundation",
+ "objc2-link-presentation",
+ "objc2-quartz-core",
+ "objc2-symbols",
+ "objc2-uniform-type-identifiers",
+ "objc2-user-notifications",
+]
+
+[[package]]
+name = "objc2-uniform-type-identifiers"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe"
+dependencies = [
+ "block2",
+ "objc2",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "objc2-user-notifications"
+version = "0.2.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3"
+dependencies = [
+ "bitflags 2.10.0",
+ "block2",
+ "objc2",
+ "objc2-core-location",
+ "objc2-foundation",
+]
+
+[[package]]
+name = "once_cell"
+version = "1.21.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
+
+[[package]]
+name = "orbclient"
+version = "0.3.50"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "52ad2c6bae700b7aa5d1cc30c59bdd3a1c180b09dbaea51e2ae2b8e1cf211fdd"
+dependencies = [
+ "libc",
+ "libredox",
+]
+
+[[package]]
+name = "ordered-float"
+version = "4.6.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7bb71e1b3fa6ca1c61f383464aaf2bb0e2f8e772a1f01d486832464de363b951"
+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 = "paste"
+version = "1.0.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
+
+[[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.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "677f1add503faace112b9f1373e43e9e054bfdd22ff1a63c1bc485eaec6a6a8a"
+dependencies = [
+ "pin-project-internal",
+]
+
+[[package]]
+name = "pin-project-internal"
+version = "1.1.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "pin-project-lite"
+version = "0.2.16"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
+
+[[package]]
+name = "pin-utils"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
+
+[[package]]
+name = "pkg-config"
+version = "0.3.32"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
+
+[[package]]
+name = "png"
+version = "0.18.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "97baced388464909d42d89643fe4361939af9b7ce7a31ee32a168f832a70f2a0"
+dependencies = [
+ "bitflags 2.10.0",
+ "crc32fast",
+ "fdeflate",
+ "flate2",
+ "miniz_oxide",
+]
+
+[[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.3",
+ "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 = "presser"
+version = "0.3.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa"
+
+[[package]]
+name = "proc-macro-crate"
+version = "3.4.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983"
+dependencies = [
+ "toml_edit",
+]
+
+[[package]]
+name = "proc-macro2"
+version = "1.0.105"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "535d180e0ecab6268a3e718bb9fd44db66bbbc256257165fc699dadf70d16fe7"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "profiling"
+version = "1.0.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773"
+
+[[package]]
+name = "pxfm"
+version = "0.1.27"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
+dependencies = [
+ "num-traits",
+]
+
+[[package]]
+name = "quick-xml"
+version = "0.38.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "quote"
+version = "1.0.43"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc74d9a594b72ae6656596548f56f667211f8a97b3d4c3d467150794690dc40a"
+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 = "range-alloc"
+version = "0.1.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c3d6831663a5098ea164f89cff59c6284e95f4e3c76ce9848d4529f5ccca9bde"
+
+[[package]]
+name = "raw-window-handle"
+version = "0.6.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+
+[[package]]
+name = "rayon"
+version = "1.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f"
+dependencies = [
+ "either",
+ "rayon-core",
+]
+
+[[package]]
+name = "rayon-core"
+version = "1.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91"
+dependencies = [
+ "crossbeam-deque",
+ "crossbeam-utils",
+]
+
+[[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.10.0",
+]
+
+[[package]]
+name = "redox_syscall"
+version = "0.7.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "49f3fe0889e69e2ae9e41f4d6c4c0181701d00e4697b356fb1f74173a5e0ee27"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[package]]
+name = "renderbud"
+version = "0.1.0"
+dependencies = [
+ "bytemuck",
+ "egui",
+ "egui-wgpu",
+ "glam",
+ "gltf",
+ "half",
+ "image",
+ "pollster",
+ "rayon",
+ "wgpu",
+ "winit",
+]
+
+[[package]]
+name = "renderdoc-sys"
+version = "1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
+
+[[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.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
+
+[[package]]
+name = "rustix"
+version = "0.38.44"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154"
+dependencies = [
+ "bitflags 2.10.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.4.15",
+ "windows-sys 0.59.0",
+]
+
+[[package]]
+name = "rustix"
+version = "1.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
+dependencies = [
+ "bitflags 2.10.0",
+ "errno",
+ "libc",
+ "linux-raw-sys 0.11.0",
+ "windows-sys 0.61.2",
+]
+
+[[package]]
+name = "rustversion"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
+
+[[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 = "serde"
+version = "1.0.228"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
+dependencies = [
+ "serde_core",
+]
+
+[[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.149"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
+dependencies = [
+ "itoa",
+ "memchr",
+ "serde",
+ "serde_core",
+ "zmij",
+]
+
+[[package]]
+name = "shlex"
+version = "1.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
+
+[[package]]
+name = "simd-adler32"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2"
+
+[[package]]
+name = "slab"
+version = "0.4.11"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7a2ae44ef20feb57a68b23d846850f861394c2e02dc425a50098ae8c90267589"
+
+[[package]]
+name = "slotmap"
+version = "1.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038"
+dependencies = [
+ "version_check",
+]
+
+[[package]]
+name = "smallvec"
+version = "1.15.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
+
+[[package]]
+name = "smithay-client-toolkit"
+version = "0.19.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016"
+dependencies = [
+ "bitflags 2.10.0",
+ "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.3.0+sdk-1.3.268.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844"
+dependencies = [
+ "bitflags 2.10.0",
+]
+
+[[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 = "strum"
+version = "0.26.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06"
+dependencies = [
+ "strum_macros",
+]
+
+[[package]]
+name = "strum_macros"
+version = "0.26.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be"
+dependencies = [
+ "heck",
+ "proc-macro2",
+ "quote",
+ "rustversion",
+ "syn",
+]
+
+[[package]]
+name = "syn"
+version = "2.0.114"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4d107df263a3013ef9b1879b0df87d706ff80f65a86ea879bd9c31f9b307c2a"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "unicode-ident",
+]
+
+[[package]]
+name = "termcolor"
+version = "1.4.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755"
+dependencies = [
+ "winapi-util",
+]
+
+[[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.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f63587ca0f12b72a0600bcba1d40081f830876000bb46dd2337a3051618f4fc8"
+dependencies = [
+ "thiserror-impl 2.0.17",
+]
+
+[[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.17"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913"
+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 = "0.7.5+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347"
+dependencies = [
+ "serde_core",
+]
+
+[[package]]
+name = "toml_edit"
+version = "0.23.10+spec-1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269"
+dependencies = [
+ "indexmap",
+ "toml_datetime",
+ "toml_parser",
+ "winnow",
+]
+
+[[package]]
+name = "toml_parser"
+version = "1.0.6+spec-1.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a3198b4b0a8e11f09dd03e133c0280504d0801269e9afa46362ffde1cbeebf44"
+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.1",
+]
+
+[[package]]
+name = "unicode-ident"
+version = "1.0.22"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9312f7c4f6ff9069b165498234ce8be658059c6728633667c526e27dc2cf1df5"
+
+[[package]]
+name = "unicode-segmentation"
+version = "1.12.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
+
+[[package]]
+name = "unicode-width"
+version = "0.1.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af"
+
+[[package]]
+name = "unicode-xid"
+version = "0.2.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
+
+[[package]]
+name = "urlencoding"
+version = "2.1.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
+
+[[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.2+wasi-0.2.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9517f9239f02c069db75e65f174b3da828fe5f5b945c4dd26bd25d89c03ebcf5"
+dependencies = [
+ "wit-bindgen",
+]
+
+[[package]]
+name = "wasm-bindgen"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "64024a30ec1e37399cf85a7ffefebdb72205ca1c972291c51512360d90bd8566"
+dependencies = [
+ "cfg-if",
+ "once_cell",
+ "rustversion",
+ "wasm-bindgen-macro",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-futures"
+version = "0.4.58"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "70a6e77fd0ae8029c9ea0063f87c46fde723e7d887703d74ad2616d792e51e6f"
+dependencies = [
+ "cfg-if",
+ "futures-util",
+ "js-sys",
+ "once_cell",
+ "wasm-bindgen",
+ "web-sys",
+]
+
+[[package]]
+name = "wasm-bindgen-macro"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "008b239d9c740232e71bd39e8ef6429d27097518b6b30bdf9086833bd5b6d608"
+dependencies = [
+ "quote",
+ "wasm-bindgen-macro-support",
+]
+
+[[package]]
+name = "wasm-bindgen-macro-support"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5256bae2d58f54820e6490f9839c49780dff84c65aeab9e772f15d5f0e913a55"
+dependencies = [
+ "bumpalo",
+ "proc-macro2",
+ "quote",
+ "syn",
+ "wasm-bindgen-shared",
+]
+
+[[package]]
+name = "wasm-bindgen-shared"
+version = "0.2.108"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1f01b580c9ac74c8d8f0c0e4afb04eeef2acf145458e52c03845ee9cd23e3d12"
+dependencies = [
+ "unicode-ident",
+]
+
+[[package]]
+name = "wayland-backend"
+version = "0.3.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "fee64194ccd96bf648f42a65a7e589547096dfa702f7cadef84347b66ad164f9"
+dependencies = [
+ "cc",
+ "downcast-rs",
+ "rustix 1.1.3",
+ "scoped-tls",
+ "smallvec",
+ "wayland-sys",
+]
+
+[[package]]
+name = "wayland-client"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b8e6faa537fbb6c186cb9f1d41f2f811a4120d1b57ec61f50da451a0c5122bec"
+dependencies = [
+ "bitflags 2.10.0",
+ "rustix 1.1.3",
+ "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.10.0",
+ "cursor-icon",
+ "wayland-backend",
+]
+
+[[package]]
+name = "wayland-cursor"
+version = "0.31.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5864c4b5b6064b06b1e8b74ead4a98a6c45a285fe7a0e784d24735f011fdb078"
+dependencies = [
+ "rustix 1.1.3",
+ "wayland-client",
+ "xcursor",
+]
+
+[[package]]
+name = "wayland-protocols"
+version = "0.32.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "baeda9ffbcfc8cd6ddaade385eaf2393bd2115a69523c735f12242353c3df4f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-plasma"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "aa98634619300a535a9a97f338aed9a5ff1e01a461943e8346ff4ae26007306b"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-protocols-wlr"
+version = "0.3.10"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e9597cdf02cf0c34cd5823786dce6b5ae8598f05c2daf5621b6e178d4f7345f3"
+dependencies = [
+ "bitflags 2.10.0",
+ "wayland-backend",
+ "wayland-client",
+ "wayland-protocols",
+ "wayland-scanner",
+]
+
+[[package]]
+name = "wayland-scanner"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
+dependencies = [
+ "proc-macro2",
+ "quick-xml",
+ "quote",
+]
+
+[[package]]
+name = "wayland-sys"
+version = "0.31.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e6dbfc3ac5ef974c92a2235805cc0114033018ae1290a72e474aa8b28cbbdfd"
+dependencies = [
+ "dlib",
+ "log",
+ "once_cell",
+ "pkg-config",
+]
+
+[[package]]
+name = "web-sys"
+version = "0.3.85"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "312e32e551d92129218ea9a2452120f4aabc03529ef03e4d0d82fb2780608598"
+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 = "24.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6b0b3436f0729f6cdf2e6e9201f3d39dc95813fad61d826c1ed07918b4539353"
+dependencies = [
+ "arrayvec",
+ "bitflags 2.10.0",
+ "cfg_aliases",
+ "document-features",
+ "js-sys",
+ "log",
+ "naga",
+ "parking_lot",
+ "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 = "24.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7f0aa306497a238d169b9dc70659105b4a096859a34894544ca81719242e1499"
+dependencies = [
+ "arrayvec",
+ "bit-vec",
+ "bitflags 2.10.0",
+ "cfg_aliases",
+ "document-features",
+ "indexmap",
+ "log",
+ "naga",
+ "once_cell",
+ "parking_lot",
+ "profiling",
+ "raw-window-handle",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.17",
+ "wgpu-hal",
+ "wgpu-types",
+]
+
+[[package]]
+name = "wgpu-hal"
+version = "24.0.4"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f112f464674ca69f3533248508ee30cb84c67cf06c25ff6800685f5e0294e259"
+dependencies = [
+ "android_system_properties",
+ "arrayvec",
+ "ash",
+ "bit-set",
+ "bitflags 2.10.0",
+ "block",
+ "bytemuck",
+ "cfg_aliases",
+ "core-graphics-types",
+ "glow",
+ "glutin_wgl_sys",
+ "gpu-alloc",
+ "gpu-allocator",
+ "gpu-descriptor",
+ "js-sys",
+ "khronos-egl",
+ "libc",
+ "libloading",
+ "log",
+ "metal",
+ "naga",
+ "ndk-sys 0.5.0+25.2.9519653",
+ "objc",
+ "once_cell",
+ "ordered-float",
+ "parking_lot",
+ "profiling",
+ "range-alloc",
+ "raw-window-handle",
+ "renderdoc-sys",
+ "rustc-hash 1.1.0",
+ "smallvec",
+ "thiserror 2.0.17",
+ "wasm-bindgen",
+ "web-sys",
+ "wgpu-types",
+ "windows",
+ "windows-core",
+]
+
+[[package]]
+name = "wgpu-types"
+version = "24.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "50ac044c0e76c03a0378e7786ac505d010a873665e2d51383dcff8dd227dc69c"
+dependencies = [
+ "bitflags 2.10.0",
+ "js-sys",
+ "log",
+ "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.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
+dependencies = [
+ "windows-core",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-core"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
+dependencies = [
+ "windows-implement",
+ "windows-interface",
+ "windows-result",
+ "windows-strings",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-implement"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "windows-interface"
+version = "0.58.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
+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-result"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-strings"
+version = "0.1.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
+dependencies = [
+ "windows-result",
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.45.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
+dependencies = [
+ "windows-targets 0.42.2",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.52.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[package]]
+name = "windows-sys"
+version = "0.59.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
+dependencies = [
+ "windows-targets 0.52.6",
+]
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
+dependencies = [
+ "windows_aarch64_gnullvm 0.42.2",
+ "windows_aarch64_msvc 0.42.2",
+ "windows_i686_gnu 0.42.2",
+ "windows_i686_msvc 0.42.2",
+ "windows_x86_64_gnu 0.42.2",
+ "windows_x86_64_gnullvm 0.42.2",
+ "windows_x86_64_msvc 0.42.2",
+]
+
+[[package]]
+name = "windows-targets"
+version = "0.52.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
+dependencies = [
+ "windows_aarch64_gnullvm 0.52.6",
+ "windows_aarch64_msvc 0.52.6",
+ "windows_i686_gnu 0.52.6",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc 0.52.6",
+ "windows_x86_64_gnu 0.52.6",
+ "windows_x86_64_gnullvm 0.52.6",
+ "windows_x86_64_msvc 0.52.6",
+]
+
+[[package]]
+name = "windows_aarch64_gnullvm"
+version = "0.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
+
+[[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.42.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"
+
+[[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.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c66d4b9ed69c4009f6321f762d6e61ad8a2389cd431b97cb1e146812e9e6c732"
+dependencies = [
+ "ahash",
+ "android-activity",
+ "atomic-waker",
+ "bitflags 2.10.0",
+ "block2",
+ "bytemuck",
+ "calloop",
+ "cfg_aliases",
+ "concurrent-queue",
+ "core-foundation",
+ "core-graphics",
+ "cursor-icon",
+ "dpi",
+ "js-sys",
+ "libc",
+ "memmap2",
+ "ndk",
+ "objc2",
+ "objc2-app-kit",
+ "objc2-foundation",
+ "objc2-ui-kit",
+ "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 = "0.7.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829"
+dependencies = [
+ "memchr",
+]
+
+[[package]]
+name = "wit-bindgen"
+version = "0.51.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
+
+[[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.3",
+ "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.10.0",
+ "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 = "xml-rs"
+version = "0.8.28"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
+
+[[package]]
+name = "zerocopy"
+version = "0.8.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "668f5168d10b9ee831de31933dc111a459c97ec93225beb307aed970d1372dfd"
+dependencies = [
+ "zerocopy-derive",
+]
+
+[[package]]
+name = "zerocopy-derive"
+version = "0.8.33"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2c7962b26b0a8685668b671ee4b54d007a67d4eaf05fda79ac0ecf41e32270f1"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn",
+]
+
+[[package]]
+name = "zmij"
+version = "1.0.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "bd8f3f50b848df28f887acb68e41201b5aea6bc8a8dacc00fb40635ff9a72fea"
+
+[[package]]
+name = "zune-core"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9"
+
+[[package]]
+name = "zune-jpeg"
+version = "0.5.9"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "87c86acb70a85b2c16f071f171847d1945e8f44812630463cd14ec83900ad01c"
+dependencies = [
+ "zune-core",
+]
diff --git a/crates/renderbud/Cargo.toml b/crates/renderbud/Cargo.toml
@@ -0,0 +1,25 @@
+[package]
+name = "renderbud"
+version = "0.1.0"
+edition = "2024"
+
+[dependencies]
+image = { workspace = true }
+bytemuck = { workspace = true }
+egui-wgpu = { workspace = true, optional = true }
+egui = { workspace = true, optional = true }
+
+glam = { version = "0.30.10", features = ["bytemuck"] }
+gltf = "1.4"
+wgpu = "24"
+half = "2.7.1"
+rayon = "1.10"
+
+[features]
+egui = ["egui-wgpu", "dep:egui"]
+
+[dev-dependencies]
+winit = "0.30"
+wgpu = "24"
+gltf = "1.4"
+pollster = "0.4.0"
diff --git a/crates/renderbud/Makefile b/crates/renderbud/Makefile
@@ -0,0 +1,5 @@
+
+tags: fake
+ rusty-tags vi
+
+.PHONY: fake
diff --git a/crates/renderbud/README.md b/crates/renderbud/README.md
@@ -0,0 +1,15 @@
+
+# renderbud
+
+A wgpu middleware renderer. I created this so I can have full fledged
+3d rendering embedded inside of [notedeck][notedeck]
+
+This is not usable at the moment, but feel free to browse.
+
+## examples
+
+```
+cargo run --example ironwood
+```
+
+[notedeck]: https://github.com/damus-io/notedeck
diff --git a/crates/renderbud/assets/kloofendal_43d_clear_1k.hdr b/crates/renderbud/assets/kloofendal_43d_clear_1k.hdr
Binary files differ.
diff --git a/crates/renderbud/assets/venice_sunset_1k.hdr b/crates/renderbud/assets/venice_sunset_1k.hdr
Binary files differ.
diff --git a/crates/renderbud/examples/ironwood.rs b/crates/renderbud/examples/ironwood.rs
@@ -0,0 +1,259 @@
+use winit::{
+ application::ApplicationHandler,
+ event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent},
+ event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
+ keyboard::{KeyCode, PhysicalKey},
+ window::{Window, WindowAttributes, WindowId},
+};
+
+struct Renderbud {
+ surface: wgpu::Surface<'static>,
+ config: wgpu::SurfaceConfiguration,
+ device: wgpu::Device,
+ queue: wgpu::Queue,
+ renderer: renderbud::Renderer,
+}
+
+impl Renderbud {
+ async fn new(window: Window) -> Self {
+ let size = window.inner_size();
+
+ let instance = wgpu::Instance::default();
+ let surface = instance.create_surface(window).unwrap();
+
+ let adapter = instance
+ .request_adapter(&wgpu::RequestAdapterOptions {
+ compatible_surface: Some(&surface),
+ force_fallback_adapter: false,
+ ..Default::default()
+ })
+ .await
+ .unwrap();
+
+ let (device, queue) = adapter
+ .request_device(
+ &wgpu::DeviceDescriptor {
+ label: None,
+ memory_hints: wgpu::MemoryHints::MemoryUsage,
+ required_features: wgpu::Features::empty(),
+ required_limits: wgpu::Limits::default(),
+ },
+ None,
+ )
+ .await
+ .unwrap();
+
+ let surface_caps = surface.get_capabilities(&adapter);
+ let format = surface_caps
+ .formats
+ .iter()
+ .copied()
+ .find(|f| f.is_srgb())
+ .unwrap_or(surface_caps.formats[0]);
+
+ let config = wgpu::SurfaceConfiguration {
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ format,
+ width: size.width.max(1),
+ height: size.height.max(1),
+ present_mode: surface_caps.present_modes[0],
+ alpha_mode: surface_caps.alpha_modes[0],
+ view_formats: vec![],
+ desired_maximum_frame_latency: 2,
+ };
+
+ surface.configure(&device, &config);
+
+ let renderer =
+ renderbud::Renderer::new(&device, &queue, format, (config.width, config.height));
+
+ Self {
+ config,
+ surface,
+ queue,
+ device,
+ renderer,
+ }
+ }
+
+ fn update(&mut self) {
+ self.renderer.update();
+ }
+
+ fn prepare(&self) {
+ self.renderer.prepare(&self.queue);
+ }
+
+ fn resize(&mut self, new_size: (u32, u32)) {
+ let width = new_size.0.max(1);
+ let height = new_size.1.max(1);
+
+ self.config.width = width;
+ self.config.height = height;
+ self.surface.configure(&self.device, &self.config);
+
+ self.renderer.set_target_size((width, height));
+ self.renderer.resize(&self.device)
+ }
+
+ fn size(&self) -> (u32, u32) {
+ self.renderer.size()
+ }
+
+ fn on_mouse_drag(&mut self, delta_x: f32, delta_y: f32) {
+ self.renderer.on_mouse_drag(delta_x, delta_y);
+ }
+
+ fn on_scroll(&mut self, delta: f32) {
+ self.renderer.on_scroll(delta);
+ }
+
+ fn load_gltf_model(
+ &mut self,
+ path: impl AsRef<std::path::Path>,
+ ) -> Result<renderbud::Model, gltf::Error> {
+ self.renderer
+ .load_gltf_model(&self.device, &self.queue, path)
+ }
+
+ fn render(&mut self) -> Result<(), wgpu::SurfaceError> {
+ let frame = self.surface.get_current_texture()?;
+ let view = frame
+ .texture
+ .create_view(&wgpu::TextureViewDescriptor::default());
+
+ let mut encoder = self
+ .device
+ .create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+
+ self.renderer.render(&view, &mut encoder);
+ self.queue.submit(Some(encoder.finish()));
+ frame.present();
+
+ Ok(())
+ }
+}
+
+struct App {
+ renderbud: Option<Renderbud>,
+ mouse_pressed: bool,
+ last_mouse_pos: Option<(f64, f64)>,
+}
+
+impl Default for App {
+ fn default() -> Self {
+ Self {
+ renderbud: None,
+ mouse_pressed: false,
+ last_mouse_pos: None,
+ }
+ }
+}
+
+impl ApplicationHandler for App {
+ fn resumed(&mut self, el: &ActiveEventLoop) {
+ // Create the window *after* the event loop is running (winit 0.30+).
+ let window: Window = el
+ .create_window(WindowAttributes::default())
+ .expect("create_window failed");
+
+ let mut renderbud = pollster::block_on(Renderbud::new(window));
+
+ // pick a path relative to crate root
+ //let model_path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/assets/ironwood.glb");
+ let model_path = std::path::Path::new("/home/jb55/var/models/WaterBottle.glb");
+ //let model_path = std::path::Path::new("/home/jb55/dev/github/KhronosGroup/glTF-Sample-Assets/Models/FlightHelmet/glTF/FlightHelmet.gltf");
+ //let model_path = std::path::Path::new("/home/jb55/var/models/acnh-scuba.glb");
+ //let model_path = std::path::Path::new("/home/jb55/var/models/ABeautifulGame.glb");
+ renderbud.load_gltf_model(model_path).unwrap();
+
+ self.renderbud = Some(renderbud);
+ }
+
+ fn window_event(&mut self, el: &ActiveEventLoop, _id: WindowId, event: WindowEvent) {
+ let Some(renderbud) = self.renderbud.as_mut() else {
+ return;
+ };
+
+ match event {
+ WindowEvent::CloseRequested => el.exit(),
+
+ WindowEvent::Resized(sz) => renderbud.resize((sz.width, sz.height)),
+
+ WindowEvent::KeyboardInput { event, .. } => {
+ if let KeyEvent {
+ physical_key: PhysicalKey::Code(code),
+ state: ElementState::Pressed,
+ ..
+ } = event
+ {
+ match code {
+ KeyCode::Space => {
+ // do something
+ }
+ _ => {}
+ }
+ }
+ }
+
+ WindowEvent::MouseInput { state, button, .. } => {
+ if button == MouseButton::Left {
+ self.mouse_pressed = state == ElementState::Pressed;
+ if !self.mouse_pressed {
+ self.last_mouse_pos = None;
+ }
+ }
+ }
+
+ WindowEvent::CursorMoved { position, .. } => {
+ let pos = (position.x, position.y);
+ if self.mouse_pressed {
+ if let Some(last) = self.last_mouse_pos {
+ let dx = (pos.0 - last.0) as f32;
+ let dy = (pos.1 - last.1) as f32;
+ renderbud.on_mouse_drag(dx, dy);
+ }
+ }
+ self.last_mouse_pos = Some(pos);
+ }
+
+ WindowEvent::MouseWheel { delta, .. } => {
+ let scroll = match delta {
+ MouseScrollDelta::LineDelta(_, y) => y,
+ MouseScrollDelta::PixelDelta(pos) => pos.y as f32 * 0.01,
+ };
+ renderbud.on_scroll(scroll);
+ }
+
+ _ => {}
+ }
+ }
+
+ fn about_to_wait(&mut self, el: &ActiveEventLoop) {
+ let Some(renderbud) = self.renderbud.as_mut() else {
+ return;
+ };
+
+ // Continuous rendering.
+ renderbud.update();
+ renderbud.prepare();
+
+ match renderbud.render() {
+ Ok(_) => {}
+ Err(wgpu::SurfaceError::Lost) => renderbud.resize(renderbud.size()),
+ Err(wgpu::SurfaceError::OutOfMemory) => el.exit(),
+ Err(_) => {}
+ }
+ }
+}
+
+fn main() -> Result<(), Box<dyn std::error::Error>> {
+ let event_loop = EventLoop::new()?;
+
+ // Equivalent to your `elwt.set_control_flow(ControlFlow::Poll);`
+ event_loop.set_control_flow(ControlFlow::Poll);
+
+ let mut app = App::default();
+ event_loop.run_app(&mut app)?;
+ Ok(())
+}
diff --git a/crates/renderbud/src/camera.rs b/crates/renderbud/src/camera.rs
@@ -0,0 +1,362 @@
+use glam::{Mat4, Vec3};
+
+#[derive(Debug, Copy, Clone)]
+pub struct Camera {
+ pub eye: Vec3,
+ pub target: Vec3,
+ pub up: Vec3,
+
+ pub fov_y: f32,
+ pub znear: f32,
+ pub zfar: f32,
+}
+
+/// Arcball camera controller for orbital navigation around a target point.
+#[derive(Debug, Clone)]
+pub struct ArcballController {
+ pub target: Vec3,
+ pub distance: f32,
+ pub yaw: f32, // radians, around Y axis
+ pub pitch: f32, // radians, up/down
+ pub sensitivity: f32,
+ pub zoom_sensitivity: f32,
+ pub min_distance: f32,
+ pub max_distance: f32,
+}
+
+impl Default for ArcballController {
+ fn default() -> Self {
+ Self {
+ target: Vec3::ZERO,
+ distance: 5.0,
+ yaw: 0.0,
+ pitch: 0.3,
+ sensitivity: 0.005,
+ zoom_sensitivity: 0.1,
+ min_distance: 0.1,
+ max_distance: 1000.0,
+ }
+ }
+}
+
+impl ArcballController {
+ /// Initialize from an existing camera.
+ pub fn from_camera(camera: &Camera) -> Self {
+ let offset = camera.eye - camera.target;
+ let distance = offset.length();
+
+ // Compute yaw (rotation around Y) and pitch (elevation)
+ let yaw = offset.x.atan2(offset.z);
+ let pitch = (offset.y / distance).asin();
+
+ Self {
+ target: camera.target,
+ distance,
+ yaw,
+ pitch,
+ ..Default::default()
+ }
+ }
+
+ /// Handle mouse drag delta (in pixels).
+ pub fn on_drag(&mut self, delta_x: f32, delta_y: f32) {
+ self.yaw -= delta_x * self.sensitivity;
+ self.pitch += delta_y * self.sensitivity;
+
+ // Clamp pitch to avoid gimbal lock
+ let limit = std::f32::consts::FRAC_PI_2 - 0.01;
+ self.pitch = self.pitch.clamp(-limit, limit);
+ }
+
+ /// Handle scroll for zoom (positive = zoom in).
+ pub fn on_scroll(&mut self, delta: f32) {
+ self.distance *= 1.0 - delta * self.zoom_sensitivity;
+ self.distance = self.distance.clamp(self.min_distance, self.max_distance);
+ }
+
+ /// Compute the camera eye position from current orbit state.
+ pub fn eye(&self) -> Vec3 {
+ let x = self.distance * self.pitch.cos() * self.yaw.sin();
+ let y = self.distance * self.pitch.sin();
+ let z = self.distance * self.pitch.cos() * self.yaw.cos();
+ self.target + Vec3::new(x, y, z)
+ }
+
+ /// Update a camera with the current arcball state.
+ pub fn update_camera(&self, camera: &mut Camera) {
+ camera.eye = self.eye();
+ camera.target = self.target;
+ }
+}
+
+/// FPS-style fly camera controller for free movement through the scene.
+#[derive(Debug, Clone)]
+pub struct FlyController {
+ pub position: Vec3,
+ pub yaw: f32, // radians, around Y axis
+ pub pitch: f32, // radians, up/down
+ pub speed: f32,
+ pub sensitivity: f32,
+}
+
+impl Default for FlyController {
+ fn default() -> Self {
+ Self {
+ position: Vec3::new(0.0, 2.0, 5.0),
+ yaw: 0.0,
+ pitch: 0.0,
+ speed: 5.0,
+ sensitivity: 0.003,
+ }
+ }
+}
+
+impl FlyController {
+ /// Initialize from an existing camera.
+ pub fn from_camera(camera: &Camera) -> Self {
+ let dir = (camera.target - camera.eye).normalize();
+ let yaw = dir.x.atan2(dir.z);
+ let pitch = dir.y.asin();
+
+ Self {
+ position: camera.eye,
+ yaw,
+ pitch,
+ ..Default::default()
+ }
+ }
+
+ /// Handle mouse movement for looking around.
+ pub fn on_mouse_look(&mut self, delta_x: f32, delta_y: f32) {
+ self.yaw -= delta_x * self.sensitivity;
+ self.pitch -= delta_y * self.sensitivity;
+
+ let limit = std::f32::consts::FRAC_PI_2 - 0.01;
+ self.pitch = self.pitch.clamp(-limit, limit);
+ }
+
+ /// Forward direction (horizontal plane + pitch).
+ pub fn forward(&self) -> Vec3 {
+ Vec3::new(
+ self.pitch.cos() * self.yaw.sin(),
+ self.pitch.sin(),
+ self.pitch.cos() * self.yaw.cos(),
+ )
+ .normalize()
+ }
+
+ /// Right direction (always horizontal).
+ pub fn right(&self) -> Vec3 {
+ Vec3::new(self.yaw.cos(), 0.0, -self.yaw.sin()).normalize()
+ }
+
+ /// Move the camera. forward/right/up are signed: positive = forward/right/up.
+ pub fn process_movement(&mut self, forward: f32, right: f32, up: f32, dt: f32) {
+ let velocity = self.speed * dt;
+ self.position += self.forward() * forward * velocity;
+ self.position += self.right() * right * velocity;
+ self.position += Vec3::Y * up * velocity;
+ }
+
+ /// Adjust speed with scroll wheel.
+ pub fn on_scroll(&mut self, delta: f32) {
+ self.speed *= 1.0 + delta * 0.1;
+ self.speed = self.speed.clamp(0.5, 100.0);
+ }
+
+ /// Update a camera with the current fly state.
+ pub fn update_camera(&self, camera: &mut Camera) {
+ camera.eye = self.position;
+ camera.target = self.position + self.forward();
+ }
+}
+
+/// Third-person camera controller that orbits around a movable avatar.
+///
+/// WASD moves the avatar on the ground plane (camera-relative).
+/// Mouse drag orbits the camera around the avatar.
+/// Scroll zooms in/out.
+#[derive(Debug, Clone)]
+pub struct ThirdPersonController {
+ /// Avatar world position (Y stays at ground level)
+ pub avatar_position: Vec3,
+ /// Avatar facing direction in radians (around Y axis)
+ pub avatar_yaw: f32,
+ /// Height offset for the camera look-at target above avatar_position
+ pub avatar_eye_height: f32,
+
+ /// Camera orbit distance from avatar
+ pub distance: f32,
+ /// Camera orbit yaw (horizontal angle around avatar)
+ pub yaw: f32,
+ /// Camera orbit pitch (vertical angle, positive = looking down)
+ pub pitch: f32,
+
+ /// Avatar movement speed (units per second)
+ pub speed: f32,
+ /// Mouse orbit sensitivity
+ pub sensitivity: f32,
+ /// Scroll zoom sensitivity
+ pub zoom_sensitivity: f32,
+ /// Minimum orbit distance
+ pub min_distance: f32,
+ /// Maximum orbit distance
+ pub max_distance: f32,
+}
+
+impl Default for ThirdPersonController {
+ fn default() -> Self {
+ Self {
+ avatar_position: Vec3::ZERO,
+ avatar_yaw: 0.0,
+ avatar_eye_height: 1.5,
+ distance: 8.0,
+ yaw: 0.0,
+ pitch: 0.4,
+ speed: 5.0,
+ sensitivity: 0.005,
+ zoom_sensitivity: 0.1,
+ min_distance: 2.0,
+ max_distance: 30.0,
+ }
+ }
+}
+
+impl ThirdPersonController {
+ /// Initialize from an existing camera, inferring orbit parameters.
+ pub fn from_camera(camera: &Camera) -> Self {
+ let offset = camera.eye - camera.target;
+ let distance = offset.length().max(2.0);
+ let yaw = offset.x.atan2(offset.z);
+ let pitch = (offset.y / distance).asin().max(0.05);
+
+ Self {
+ avatar_position: Vec3::new(camera.target.x, 0.0, camera.target.z),
+ avatar_eye_height: camera.target.y.max(1.0),
+ distance,
+ yaw,
+ pitch,
+ ..Default::default()
+ }
+ }
+
+ /// Handle mouse drag to orbit camera around avatar.
+ pub fn on_mouse_look(&mut self, delta_x: f32, delta_y: f32) {
+ self.yaw -= delta_x * self.sensitivity;
+ self.pitch += delta_y * self.sensitivity;
+
+ let limit = std::f32::consts::FRAC_PI_2 - 0.05;
+ self.pitch = self.pitch.clamp(0.05, limit);
+ }
+
+ /// Handle scroll to zoom in/out.
+ pub fn on_scroll(&mut self, delta: f32) {
+ self.distance *= 1.0 - delta * self.zoom_sensitivity;
+ self.distance = self.distance.clamp(self.min_distance, self.max_distance);
+ }
+
+ /// Camera forward direction projected onto the ground plane.
+ fn camera_forward_flat(&self) -> Vec3 {
+ Vec3::new(self.yaw.sin(), 0.0, self.yaw.cos()).normalize()
+ }
+
+ /// Camera right direction (always horizontal).
+ fn camera_right(&self) -> Vec3 {
+ Vec3::new(self.yaw.cos(), 0.0, -self.yaw.sin()).normalize()
+ }
+
+ /// Move avatar on the ground plane (camera-relative WASD).
+ /// `_up` is ignored -- avatar stays on the ground.
+ pub fn process_movement(&mut self, forward: f32, right: f32, _up: f32, dt: f32) {
+ let velocity = self.speed * dt;
+ let move_dir = self.camera_forward_flat() * forward + self.camera_right() * right;
+
+ if move_dir.length_squared() > 0.001 {
+ let move_dir = move_dir.normalize();
+ self.avatar_position += move_dir * velocity;
+ self.avatar_yaw = move_dir.x.atan2(move_dir.z);
+ }
+ }
+
+ /// Camera look-at target (avatar position + eye height offset).
+ pub fn target(&self) -> Vec3 {
+ self.avatar_position + Vec3::new(0.0, self.avatar_eye_height, 0.0)
+ }
+
+ /// Compute camera eye position from orbit state.
+ pub fn eye(&self) -> Vec3 {
+ let target = self.target();
+ let x = self.distance * self.pitch.cos() * self.yaw.sin();
+ let y = self.distance * self.pitch.sin();
+ let z = self.distance * self.pitch.cos() * self.yaw.cos();
+ target + Vec3::new(x, y, z)
+ }
+
+ /// Update a Camera struct from current orbit + avatar state.
+ pub fn update_camera(&self, camera: &mut Camera) {
+ camera.eye = self.eye();
+ camera.target = self.target();
+ }
+}
+
+impl Camera {
+ pub fn new(eye: Vec3, target: Vec3) -> Self {
+ Self {
+ eye,
+ target,
+ up: Vec3::Y,
+ fov_y: 45_f32.to_radians(),
+ znear: 0.1,
+ zfar: 1000.0,
+ }
+ }
+
+ fn view(&self) -> Mat4 {
+ Mat4::look_at_rh(self.eye, self.target, self.up)
+ }
+
+ fn proj(&self, width: f32, height: f32) -> Mat4 {
+ let aspect = width / height.max(1.0);
+ Mat4::perspective_rh(self.fov_y, aspect, self.znear, self.zfar)
+ }
+
+ pub fn view_proj(&self, width: f32, height: f32) -> Mat4 {
+ self.proj(width, height) * self.view()
+ }
+
+ pub fn fit_to_aabb(
+ bounds_min: Vec3,
+ bounds_max: Vec3,
+ aspect: f32,
+ fov_y: f32,
+ padding: f32,
+ ) -> Self {
+ let center = (bounds_min + bounds_max) * 0.5;
+ let radius = ((bounds_max - bounds_min) * 0.5).length().max(1e-4);
+
+ // horizontal fov derived from vertical fov + aspect
+ let half_fov_y = fov_y * 0.5;
+ let half_fov_x = (half_fov_y.tan() * aspect).atan();
+
+ // fit in both directions
+ let limiting_half_fov = half_fov_y.min(half_fov_x);
+ let dist = (radius / limiting_half_fov.tan()) * padding;
+
+ // choose a viewing direction
+ let view_dir = Vec3::new(0.0, 0.35, 1.0).normalize();
+ let eye = center + view_dir * dist;
+
+ // near/far based on distance + radius
+ let znear = (dist - radius * 2.0).max(0.01);
+ let zfar = dist + radius * 50.0;
+
+ Self {
+ eye,
+ target: center,
+ up: Vec3::Y,
+ fov_y,
+ znear,
+ zfar,
+ }
+ }
+}
diff --git a/crates/renderbud/src/egui.rs b/crates/renderbud/src/egui.rs
@@ -0,0 +1,73 @@
+use std::sync::Arc;
+use std::sync::Mutex;
+
+use crate::Renderer;
+
+#[derive(Clone)]
+pub struct EguiRenderer {
+ pub renderer: Arc<Mutex<Renderer>>,
+}
+
+/// Marker type for the egui paint callback that renders the full scene.
+#[derive(Copy, Clone)]
+pub struct SceneRender;
+
+#[cfg(feature = "egui")]
+impl EguiRenderer {
+ pub fn new(rs: &egui_wgpu::RenderState, size: (u32, u32)) -> Self {
+ let renderer = Renderer::new(&rs.device, &rs.queue, rs.target_format, size);
+ let egui_renderer = Self {
+ renderer: Arc::new(Mutex::new(renderer)),
+ };
+
+ rs.renderer
+ .write()
+ .callback_resources
+ .insert(egui_renderer.clone());
+
+ egui_renderer
+ }
+}
+
+#[cfg(feature = "egui")]
+impl egui_wgpu::CallbackTrait for SceneRender {
+ fn prepare(
+ &self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ _screen_descriptor: &egui_wgpu::ScreenDescriptor,
+ _egui_encoder: &mut wgpu::CommandEncoder,
+ resources: &mut egui_wgpu::CallbackResources,
+ ) -> Vec<wgpu::CommandBuffer> {
+ let egui_renderer: &EguiRenderer = resources.get().unwrap();
+
+ let mut renderer = egui_renderer.renderer.lock().unwrap();
+
+ renderer.resize(device);
+ renderer.update();
+ renderer.prepare(queue);
+
+ // Render shadow depth pass into a separate command buffer
+ // that executes before the main egui render pass.
+ let mut encoder =
+ device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None });
+ renderer.render_shadow(&mut encoder);
+
+ vec![encoder.finish()]
+ }
+
+ fn paint(
+ &self,
+ _info: egui::PaintCallbackInfo,
+ render_pass: &mut wgpu::RenderPass<'_>,
+ resources: &egui_wgpu::CallbackResources,
+ ) {
+ let egui_renderer: &EguiRenderer = resources.get().unwrap();
+
+ egui_renderer
+ .renderer
+ .lock()
+ .unwrap()
+ .render_pass(render_pass)
+ }
+}
diff --git a/crates/renderbud/src/grid.wgsl b/crates/renderbud/src/grid.wgsl
@@ -0,0 +1,144 @@
+struct Globals {
+ time: f32,
+ _pad0: f32,
+ resolution: vec2<f32>,
+
+ cam_pos: vec3<f32>,
+ _pad3: f32,
+
+ light_dir: vec3<f32>,
+ _pad1: f32,
+
+ light_color: vec3<f32>,
+ _pad2: f32,
+
+ fill_light_dir: vec3<f32>,
+ _pad4: f32,
+
+ fill_light_color: vec3<f32>,
+ _pad5: f32,
+
+ view_proj: mat4x4<f32>,
+ inv_view_proj: mat4x4<f32>,
+ light_view_proj: mat4x4<f32>,
+};
+
+@group(0) @binding(0) var<uniform> globals: Globals;
+@group(0) @binding(1) var shadow_map: texture_depth_2d;
+@group(0) @binding(2) var shadow_sampler: sampler_comparison;
+
+struct VSOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) near_point: vec3<f32>,
+ @location(1) far_point: vec3<f32>,
+};
+
+fn unproject(clip: vec2<f32>, z: f32) -> vec3<f32> {
+ let p = globals.inv_view_proj * vec4<f32>(clip, z, 1.0);
+ return p.xyz / p.w;
+}
+
+@vertex
+fn vs_main(@builtin(vertex_index) vi: u32) -> VSOut {
+ var out: VSOut;
+
+ // Fullscreen triangle: vertices at (-1,-1), (3,-1), (-1,3)
+ let x = f32((vi << 1u) & 2u) * 2.0 - 1.0;
+ let y = f32(vi & 2u) * 2.0 - 1.0;
+ out.clip = vec4<f32>(x, y, 0.0, 1.0);
+
+ // Unproject near and far planes to world space
+ out.near_point = unproject(vec2<f32>(x, y), 0.0);
+ out.far_point = unproject(vec2<f32>(x, y), 1.0);
+
+ return out;
+}
+
+// Compute grid intensity for a given world-space xz coordinate and grid spacing
+fn grid_line(coord: vec2<f32>, spacing: f32, line_width: f32) -> f32 {
+ let grid = abs(fract(coord / spacing - 0.5) - 0.5) * spacing;
+ let dxz = fwidth(coord);
+ let width = dxz * line_width;
+ let line = smoothstep(width, vec2<f32>(0.0), grid);
+ return max(line.x, line.y);
+}
+
+fn calc_shadow(world_pos: vec3<f32>) -> f32 {
+ let light_clip = globals.light_view_proj * vec4<f32>(world_pos, 1.0);
+ let ndc = light_clip.xyz / light_clip.w;
+ let shadow_uv = vec2<f32>(ndc.x * 0.5 + 0.5, -ndc.y * 0.5 + 0.5);
+
+ if shadow_uv.x < 0.0 || shadow_uv.x > 1.0 || shadow_uv.y < 0.0 || shadow_uv.y > 1.0 {
+ return 1.0;
+ }
+
+ let ref_depth = ndc.z;
+ let texel_size = 1.0 / 2048.0;
+ var shadow = 0.0;
+ for (var y = -1i; y <= 1i; y++) {
+ for (var x = -1i; x <= 1i; x++) {
+ let offset = vec2<f32>(f32(x), f32(y)) * texel_size;
+ shadow += textureSampleCompareLevel(
+ shadow_map, shadow_sampler, shadow_uv + offset, ref_depth,
+ );
+ }
+ }
+ return shadow / 9.0;
+}
+
+struct FragOut {
+ @location(0) color: vec4<f32>,
+ @builtin(frag_depth) depth: f32,
+};
+
+@fragment
+fn fs_main(in: VSOut) -> FragOut {
+ var out: FragOut;
+
+ // Ray from near to far point
+ let ray_dir = in.far_point - in.near_point;
+
+ // Intersect y=0 plane: near.y + t * dir.y = 0
+ let t = -in.near_point.y / ray_dir.y;
+
+ // Discard if no intersection (ray parallel or pointing away)
+ if t < 0.0 {
+ discard;
+ }
+
+ // World position on the grid plane
+ let world_pos = in.near_point + t * ray_dir;
+ let xz = vec2<f32>(world_pos.x, world_pos.z);
+
+ // Distance from camera for fading
+ let dist = length(world_pos - globals.cam_pos);
+
+ // Grid lines
+ let minor = grid_line(xz, 0.25, 0.5); // 0.25m subdivisions
+ let major = grid_line(xz, 1.0, 1.0); // 1.0m major lines
+
+ // Combine: major lines are brighter
+ let grid_val = max(minor * 0.3, major * 0.6);
+
+ // Fade with distance (start fading at 10m, fully gone at 80m)
+ let fade = 1.0 - smoothstep(10.0, 80.0, dist);
+
+ let alpha = grid_val * fade;
+
+ // Discard fully transparent fragments
+ if alpha < 0.001 {
+ discard;
+ }
+
+ // Shadow: darken the grid where objects cast shadows
+ let shadow = calc_shadow(world_pos);
+ let brightness = mix(0.15, 0.5, shadow);
+
+ out.color = vec4<f32>(brightness, brightness, brightness, alpha);
+
+ // Compute proper depth from world position
+ let clip = globals.view_proj * vec4<f32>(world_pos, 1.0);
+ out.depth = clip.z / clip.w;
+
+ return out;
+}
diff --git a/crates/renderbud/src/ibl.rs b/crates/renderbud/src/ibl.rs
@@ -0,0 +1,947 @@
+use rayon::prelude::*;
+use std::path::Path;
+
+pub struct IblData {
+ pub irradiance_view: wgpu::TextureView,
+ pub prefiltered_view: wgpu::TextureView,
+ pub brdf_lut_view: wgpu::TextureView,
+ pub sampler: wgpu::Sampler,
+ pub bindgroup: wgpu::BindGroup,
+}
+
+pub fn create_ibl_bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("ibl_bgl"),
+ entries: &[
+ // binding 0: irradiance cubemap
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ multisampled: false,
+ view_dimension: wgpu::TextureViewDimension::Cube,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ },
+ count: None,
+ },
+ // binding 1: sampler
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ // binding 2: pre-filtered environment cubemap (with mipmaps)
+ wgpu::BindGroupLayoutEntry {
+ binding: 2,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ multisampled: false,
+ view_dimension: wgpu::TextureViewDimension::Cube,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ },
+ count: None,
+ },
+ // binding 3: BRDF LUT (2D texture)
+ wgpu::BindGroupLayoutEntry {
+ binding: 3,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ multisampled: false,
+ view_dimension: wgpu::TextureViewDimension::D2,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ },
+ count: None,
+ },
+ ],
+ })
+}
+
+/// Create IBL data with a procedural gradient cubemap for testing.
+/// Replace this with a real irradiance map later.
+#[allow(dead_code)]
+pub fn create_test_ibl(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layout: &wgpu::BindGroupLayout,
+) -> IblData {
+ let size = 32u32; // small for testing
+ let irradiance_view = create_gradient_cubemap(device, queue, size);
+
+ // For test IBL, use the same gradient cubemap for prefiltered (not accurate but works)
+ let prefiltered_view = create_test_prefiltered_cubemap(device, queue, 64, 5);
+
+ // Generate BRDF LUT (this is environment-independent)
+ let brdf_lut_view = generate_brdf_lut(device, queue, 256);
+
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("ibl_sampler"),
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
+ address_mode_w: wgpu::AddressMode::ClampToEdge,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ mipmap_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+
+ let bindgroup = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("ibl_bg"),
+ layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&irradiance_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(&prefiltered_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(&brdf_lut_view),
+ },
+ ],
+ });
+
+ IblData {
+ irradiance_view,
+ prefiltered_view,
+ brdf_lut_view,
+ sampler,
+ bindgroup,
+ }
+}
+
+/// Creates a simple gradient cubemap for testing IBL pipeline.
+/// Sky-ish blue on top, ground-ish brown on bottom, neutral sides.
+fn create_gradient_cubemap(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ size: u32,
+) -> wgpu::TextureView {
+ let extent = wgpu::Extent3d {
+ width: size,
+ height: size,
+ depth_or_array_layers: 6,
+ };
+
+ // Use Rgba16Float for HDR values > 1.0
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("irradiance_cubemap"),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba16Float,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ // Face order: +X, -X, +Y, -Y, +Z, -Z
+ // HDR values - will be tonemapped in shader
+ let face_colors: [[f32; 3]; 6] = [
+ [0.4, 0.38, 0.35], // +X (right) - warm neutral
+ [0.35, 0.38, 0.4], // -X (left) - cool neutral
+ [0.5, 0.6, 0.8], // +Y (up/sky) - blue sky
+ [0.25, 0.2, 0.15], // -Y (down/ground) - brown ground
+ [0.4, 0.4, 0.4], // +Z (front) - neutral
+ [0.38, 0.38, 0.42], // -Z (back) - slightly cool
+ ];
+
+ let bytes_per_pixel = 8usize; // 4 x f16 = 8 bytes
+ let unpadded_row = size as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+ let padded_row = unpadded_row.div_ceil(align) * align;
+
+ for (face_idx, color) in face_colors.iter().enumerate() {
+ let mut data = vec![0u8; padded_row * size as usize];
+
+ for y in 0..size {
+ for x in 0..size {
+ let offset = (y as usize * padded_row) + (x as usize * bytes_per_pixel);
+ let r = half::f16::from_f32(color[0]);
+ let g = half::f16::from_f32(color[1]);
+ let b = half::f16::from_f32(color[2]);
+ let a = half::f16::from_f32(1.0);
+
+ data[offset..offset + 2].copy_from_slice(&r.to_le_bytes());
+ data[offset + 2..offset + 4].copy_from_slice(&g.to_le_bytes());
+ data[offset + 4..offset + 6].copy_from_slice(&b.to_le_bytes());
+ data[offset + 6..offset + 8].copy_from_slice(&a.to_le_bytes());
+ }
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d {
+ x: 0,
+ y: 0,
+ z: face_idx as u32,
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ &data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_row as u32),
+ rows_per_image: Some(size),
+ },
+ wgpu::Extent3d {
+ width: size,
+ height: size,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
+
+ texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some("irradiance_cubemap_view"),
+ dimension: Some(wgpu::TextureViewDimension::Cube),
+ ..Default::default()
+ })
+}
+
+/// Creates a simple test prefiltered cubemap with mip levels.
+/// Uses solid colors that get darker with higher mip levels (simulating blur).
+#[allow(dead_code)]
+fn create_test_prefiltered_cubemap(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ face_size: u32,
+ mip_count: u32,
+) -> wgpu::TextureView {
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("test_prefiltered_cubemap"),
+ size: wgpu::Extent3d {
+ width: face_size,
+ height: face_size,
+ depth_or_array_layers: 6,
+ },
+ mip_level_count: mip_count,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba16Float,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ // Face colors (same as gradient cubemap)
+ let face_colors: [[f32; 3]; 6] = [
+ [0.4, 0.38, 0.35],
+ [0.35, 0.38, 0.4],
+ [0.5, 0.6, 0.8],
+ [0.25, 0.2, 0.15],
+ [0.4, 0.4, 0.4],
+ [0.38, 0.38, 0.42],
+ ];
+
+ for mip in 0..mip_count {
+ let mip_size = face_size >> mip;
+ let bytes_per_pixel = 8usize;
+ let unpadded_row = mip_size as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+ let padded_row = unpadded_row.div_ceil(align) * align;
+
+ for (face_idx, color) in face_colors.iter().enumerate() {
+ let mut data = vec![0u8; padded_row * mip_size as usize];
+
+ for y in 0..mip_size {
+ for x in 0..mip_size {
+ let offset = (y as usize * padded_row) + (x as usize * bytes_per_pixel);
+ let r = half::f16::from_f32(color[0]);
+ let g = half::f16::from_f32(color[1]);
+ let b = half::f16::from_f32(color[2]);
+ let a = half::f16::from_f32(1.0);
+
+ data[offset..offset + 2].copy_from_slice(&r.to_le_bytes());
+ data[offset + 2..offset + 4].copy_from_slice(&g.to_le_bytes());
+ data[offset + 4..offset + 6].copy_from_slice(&b.to_le_bytes());
+ data[offset + 6..offset + 8].copy_from_slice(&a.to_le_bytes());
+ }
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: mip,
+ origin: wgpu::Origin3d {
+ x: 0,
+ y: 0,
+ z: face_idx as u32,
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ &data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_row as u32),
+ rows_per_image: Some(mip_size),
+ },
+ wgpu::Extent3d {
+ width: mip_size,
+ height: mip_size,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
+ }
+
+ texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some("test_prefiltered_cubemap_view"),
+ dimension: Some(wgpu::TextureViewDimension::Cube),
+ ..Default::default()
+ })
+}
+
+/// Load an HDR environment map from an equirectangular panorama file.
+pub fn load_hdr_ibl(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layout: &wgpu::BindGroupLayout,
+ path: impl AsRef<Path>,
+) -> Result<IblData, image::ImageError> {
+ let img = image::open(path)?.into_rgb32f();
+ load_hdr_ibl_from_image(device, queue, layout, img)
+}
+
+/// Load an HDR environment map from raw bytes (e.g. from `include_bytes!`).
+pub fn load_hdr_ibl_from_bytes(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layout: &wgpu::BindGroupLayout,
+ bytes: &[u8],
+) -> Result<IblData, image::ImageError> {
+ let img = image::load_from_memory(bytes)?.into_rgb32f();
+ load_hdr_ibl_from_image(device, queue, layout, img)
+}
+
+fn load_hdr_ibl_from_image(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ layout: &wgpu::BindGroupLayout,
+ img: image::Rgb32FImage,
+) -> Result<IblData, image::ImageError> {
+ let width = img.width();
+ let height = img.height();
+ let pixels: Vec<_> = img.pixels().cloned().collect();
+
+ // Convolve for diffuse irradiance (CPU-side, relatively slow but correct)
+ let irradiance_view = equirect_to_irradiance_cubemap(device, queue, &pixels, width, height, 32);
+
+ // Generate pre-filtered specular cubemap with mip chain
+ let prefiltered_view =
+ generate_prefiltered_cubemap(device, queue, &pixels, width, height, 128, 5);
+
+ // Generate BRDF integration LUT (environment-independent, could be cached)
+ let brdf_lut_view = generate_brdf_lut(device, queue, 256);
+
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("ibl_sampler"),
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
+ address_mode_w: wgpu::AddressMode::ClampToEdge,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ mipmap_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+
+ let bindgroup = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("ibl_bg"),
+ layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::TextureView(&irradiance_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(&prefiltered_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(&brdf_lut_view),
+ },
+ ],
+ });
+
+ Ok(IblData {
+ irradiance_view,
+ prefiltered_view,
+ brdf_lut_view,
+ sampler,
+ bindgroup,
+ })
+}
+
+/// Convert equirectangular panorama to irradiance cubemap (with hemisphere convolution).
+fn equirect_to_irradiance_cubemap(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ pixels: &[image::Rgb<f32>],
+ src_width: u32,
+ src_height: u32,
+ face_size: u32,
+) -> wgpu::TextureView {
+ let extent = wgpu::Extent3d {
+ width: face_size,
+ height: face_size,
+ depth_or_array_layers: 6,
+ };
+
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("irradiance_cubemap"),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba16Float,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ let bytes_per_pixel = 8usize;
+ let unpadded_row = face_size as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+ let padded_row = unpadded_row.div_ceil(align) * align;
+
+ for face in 0..6 {
+ // Compute all pixels in parallel
+ let pixel_colors: Vec<[f32; 3]> = (0..face_size * face_size)
+ .into_par_iter()
+ .map(|idx| {
+ let x = idx % face_size;
+ let y = idx / face_size;
+ let u = (x as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
+ let v = (y as f32 + 0.5) / face_size as f32 * 2.0 - 1.0;
+
+ let dir = face_uv_to_direction(face, u, v);
+ let n = normalize(dir);
+ convolve_irradiance(pixels, src_width, src_height, n)
+ })
+ .collect();
+
+ // Write results to buffer
+ let mut data = vec![0u8; padded_row * face_size as usize];
+ for (idx, color) in pixel_colors.iter().enumerate() {
+ let x = idx as u32 % face_size;
+ let y = idx as u32 / face_size;
+ let offset = (y as usize * padded_row) + (x as usize * bytes_per_pixel);
+
+ let r = half::f16::from_f32(color[0]);
+ let g = half::f16::from_f32(color[1]);
+ let b = half::f16::from_f32(color[2]);
+ let a = half::f16::from_f32(1.0);
+
+ data[offset..offset + 2].copy_from_slice(&r.to_le_bytes());
+ data[offset + 2..offset + 4].copy_from_slice(&g.to_le_bytes());
+ data[offset + 4..offset + 6].copy_from_slice(&b.to_le_bytes());
+ data[offset + 6..offset + 8].copy_from_slice(&a.to_le_bytes());
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d {
+ x: 0,
+ y: 0,
+ z: face,
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ &data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_row as u32),
+ rows_per_image: Some(face_size),
+ },
+ wgpu::Extent3d {
+ width: face_size,
+ height: face_size,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
+
+ texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some("irradiance_cubemap_view"),
+ dimension: Some(wgpu::TextureViewDimension::Cube),
+ ..Default::default()
+ })
+}
+
+fn normalize(v: [f32; 3]) -> [f32; 3] {
+ let len = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
+ [v[0] / len, v[1] / len, v[2] / len]
+}
+
+/// Integrate the environment map over a hemisphere for diffuse irradiance.
+/// Uses discrete sampling over the hemisphere.
+fn convolve_irradiance(
+ pixels: &[image::Rgb<f32>],
+ width: u32,
+ height: u32,
+ normal: [f32; 3],
+) -> [f32; 3] {
+ let mut irradiance = [0.0f32; 3];
+
+ // Build tangent frame from normal
+ let up = if normal[1].abs() < 0.999 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [1.0, 0.0, 0.0]
+ };
+ let tangent = normalize(cross(up, normal));
+ let bitangent = cross(normal, tangent);
+
+ // Sample hemisphere with uniform spacing
+ let sample_delta = 0.05; // Adjust for quality vs speed
+ let mut n_samples = 0u32;
+
+ let mut phi = 0.0f32;
+ while phi < 2.0 * std::f32::consts::PI {
+ let mut theta = 0.0f32;
+ while theta < 0.5 * std::f32::consts::PI {
+ // Spherical to cartesian (in tangent space)
+ let sin_theta = theta.sin();
+ let cos_theta = theta.cos();
+ let sin_phi = phi.sin();
+ let cos_phi = phi.cos();
+
+ let tangent_sample = [sin_theta * cos_phi, sin_theta * sin_phi, cos_theta];
+
+ // Transform to world space
+ let sample_dir = [
+ tangent_sample[0] * tangent[0]
+ + tangent_sample[1] * bitangent[0]
+ + tangent_sample[2] * normal[0],
+ tangent_sample[0] * tangent[1]
+ + tangent_sample[1] * bitangent[1]
+ + tangent_sample[2] * normal[1],
+ tangent_sample[0] * tangent[2]
+ + tangent_sample[1] * bitangent[2]
+ + tangent_sample[2] * normal[2],
+ ];
+
+ let color = sample_equirect(pixels, width, height, sample_dir);
+
+ // Weight by cos(theta) * sin(theta) for hemisphere integration
+ let weight = cos_theta * sin_theta;
+ irradiance[0] += color[0] * weight;
+ irradiance[1] += color[1] * weight;
+ irradiance[2] += color[2] * weight;
+ n_samples += 1;
+
+ theta += sample_delta;
+ }
+ phi += sample_delta;
+ }
+
+ // Normalize
+ let scale = std::f32::consts::PI / n_samples as f32;
+ [
+ irradiance[0] * scale,
+ irradiance[1] * scale,
+ irradiance[2] * scale,
+ ]
+}
+
+fn cross(a: [f32; 3], b: [f32; 3]) -> [f32; 3] {
+ [
+ a[1] * b[2] - a[2] * b[1],
+ a[2] * b[0] - a[0] * b[2],
+ a[0] * b[1] - a[1] * b[0],
+ ]
+}
+
+/// Convert face index + UV to 3D direction.
+/// Face order: +X, -X, +Y, -Y, +Z, -Z
+fn face_uv_to_direction(face: u32, u: f32, v: f32) -> [f32; 3] {
+ match face {
+ 0 => [1.0, -v, -u], // +X
+ 1 => [-1.0, -v, u], // -X
+ 2 => [u, 1.0, v], // +Y
+ 3 => [u, -1.0, -v], // -Y
+ 4 => [u, -v, 1.0], // +Z
+ 5 => [-u, -v, -1.0], // -Z
+ _ => [0.0, 0.0, 1.0],
+ }
+}
+
+/// Sample equirectangular panorama given a 3D direction.
+fn sample_equirect(pixels: &[image::Rgb<f32>], width: u32, height: u32, dir: [f32; 3]) -> [f32; 3] {
+ let len = (dir[0] * dir[0] + dir[1] * dir[1] + dir[2] * dir[2]).sqrt();
+ let x = dir[0] / len;
+ let y = dir[1] / len;
+ let z = dir[2] / len;
+
+ // Convert to spherical (theta = azimuth, phi = elevation)
+ let theta = z.atan2(x); // -PI to PI
+ let phi = y.asin(); // -PI/2 to PI/2
+
+ // Convert to UV
+ let u = (theta / std::f32::consts::PI + 1.0) * 0.5; // 0 to 1
+ let v = (-phi / std::f32::consts::FRAC_PI_2 + 1.0) * 0.5; // 0 to 1
+
+ let px = ((u * width as f32) as u32).min(width - 1);
+ let py = ((v * height as f32) as u32).min(height - 1);
+
+ let idx = (py * width + px) as usize;
+ let p = &pixels[idx];
+ [p.0[0], p.0[1], p.0[2]]
+}
+
+// ============================================================================
+// Specular IBL: Pre-filtered environment map and BRDF LUT
+// ============================================================================
+
+/// Generate a 2D BRDF integration LUT for split-sum approximation.
+/// X axis: NdotV (0..1), Y axis: roughness (0..1)
+/// Output: RG16Float with (scale, bias) for Fresnel term
+fn generate_brdf_lut(device: &wgpu::Device, queue: &wgpu::Queue, size: u32) -> wgpu::TextureView {
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("brdf_lut"),
+ size: wgpu::Extent3d {
+ width: size,
+ height: size,
+ depth_or_array_layers: 1,
+ },
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rg16Float,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ let bytes_per_pixel = 4usize; // 2 x f16 = 4 bytes
+ let unpadded_row = size as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+ let padded_row = unpadded_row.div_ceil(align) * align;
+
+ let sample_count = 1024u32;
+
+ // Compute all BRDF values in parallel
+ let brdf_values: Vec<(f32, f32)> = (0..size * size)
+ .into_par_iter()
+ .map(|idx| {
+ let x = idx % size;
+ let y = idx / size;
+ let ndot_v = (x as f32 + 0.5) / size as f32;
+ let roughness = (y as f32 + 0.5) / size as f32;
+ integrate_brdf(ndot_v.max(0.001), roughness, sample_count)
+ })
+ .collect();
+
+ // Write results to buffer
+ let mut data = vec![0u8; padded_row * size as usize];
+ for (idx, (scale, bias)) in brdf_values.iter().enumerate() {
+ let x = idx as u32 % size;
+ let y = idx as u32 / size;
+ let offset = (y as usize * padded_row) + (x as usize * bytes_per_pixel);
+
+ let r = half::f16::from_f32(*scale);
+ let g = half::f16::from_f32(*bias);
+
+ data[offset..offset + 2].copy_from_slice(&r.to_le_bytes());
+ data[offset + 2..offset + 4].copy_from_slice(&g.to_le_bytes());
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ &data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_row as u32),
+ rows_per_image: Some(size),
+ },
+ wgpu::Extent3d {
+ width: size,
+ height: size,
+ depth_or_array_layers: 1,
+ },
+ );
+
+ texture.create_view(&wgpu::TextureViewDescriptor::default())
+}
+
+/// Integrate the BRDF over the hemisphere using importance sampling.
+/// Returns (scale, bias) for the split-sum: F0 * scale + bias
+fn integrate_brdf(ndot_v: f32, roughness: f32, sample_count: u32) -> (f32, f32) {
+ // View direction in tangent space (N = [0,0,1])
+ let v = [
+ (1.0 - ndot_v * ndot_v).sqrt(), // sin(theta)
+ 0.0,
+ ndot_v, // cos(theta)
+ ];
+
+ let mut a = 0.0f32;
+ let mut b = 0.0f32;
+
+ let alpha = roughness * roughness;
+
+ for i in 0..sample_count {
+ // Hammersley sequence for quasi-random sampling
+ let (xi_x, xi_y) = hammersley(i, sample_count);
+
+ // Importance sample GGX
+ let h = importance_sample_ggx(xi_x, xi_y, alpha);
+
+ // Compute light direction by reflecting view around half vector
+ let v_dot_h = dot(v, h).max(0.0);
+ let l = [
+ 2.0 * v_dot_h * h[0] - v[0],
+ 2.0 * v_dot_h * h[1] - v[1],
+ 2.0 * v_dot_h * h[2] - v[2],
+ ];
+
+ let n_dot_l = l[2].max(0.0); // N = [0,0,1]
+ let n_dot_h = h[2].max(0.0);
+
+ if n_dot_l > 0.0 {
+ let g = geometry_smith(ndot_v, n_dot_l, roughness);
+ let g_vis = (g * v_dot_h) / (n_dot_h * ndot_v).max(0.001);
+ let fc = (1.0 - v_dot_h).powf(5.0);
+
+ a += (1.0 - fc) * g_vis;
+ b += fc * g_vis;
+ }
+ }
+
+ let inv_samples = 1.0 / sample_count as f32;
+ (a * inv_samples, b * inv_samples)
+}
+
+/// Hammersley quasi-random sequence
+fn hammersley(i: u32, n: u32) -> (f32, f32) {
+ (i as f32 / n as f32, radical_inverse_vdc(i))
+}
+
+/// Van der Corput radical inverse
+fn radical_inverse_vdc(mut bits: u32) -> f32 {
+ bits = (bits << 16) | (bits >> 16);
+ bits = ((bits & 0x55555555) << 1) | ((bits & 0xAAAAAAAA) >> 1);
+ bits = ((bits & 0x33333333) << 2) | ((bits & 0xCCCCCCCC) >> 2);
+ bits = ((bits & 0x0F0F0F0F) << 4) | ((bits & 0xF0F0F0F0) >> 4);
+ bits = ((bits & 0x00FF00FF) << 8) | ((bits & 0xFF00FF00) >> 8);
+ bits as f32 * 2.3283064365386963e-10 // 0x100000000
+}
+
+/// Importance sample the GGX NDF to get a half-vector in tangent space
+fn importance_sample_ggx(xi_x: f32, xi_y: f32, alpha: f32) -> [f32; 3] {
+ let a2 = alpha * alpha;
+
+ let phi = 2.0 * std::f32::consts::PI * xi_x;
+ let cos_theta = ((1.0 - xi_y) / (1.0 + (a2 - 1.0) * xi_y)).sqrt();
+ let sin_theta = (1.0 - cos_theta * cos_theta).sqrt();
+
+ [sin_theta * phi.cos(), sin_theta * phi.sin(), cos_theta]
+}
+
+/// Smith geometry function for GGX
+fn geometry_smith(n_dot_v: f32, n_dot_l: f32, roughness: f32) -> f32 {
+ let r = roughness + 1.0;
+ let k = (r * r) / 8.0;
+
+ let g1_v = n_dot_v / (n_dot_v * (1.0 - k) + k);
+ let g1_l = n_dot_l / (n_dot_l * (1.0 - k) + k);
+ g1_v * g1_l
+}
+
+fn dot(a: [f32; 3], b: [f32; 3]) -> f32 {
+ a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
+}
+
+/// Generate pre-filtered environment cubemap with mip levels for different roughness.
+fn generate_prefiltered_cubemap(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ pixels: &[image::Rgb<f32>],
+ src_width: u32,
+ src_height: u32,
+ face_size: u32,
+ mip_count: u32,
+) -> wgpu::TextureView {
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("prefiltered_cubemap"),
+ size: wgpu::Extent3d {
+ width: face_size,
+ height: face_size,
+ depth_or_array_layers: 6,
+ },
+ mip_level_count: mip_count,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Rgba16Float,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ let sample_count = 512u32;
+
+ for mip in 0..mip_count {
+ let mip_size = face_size >> mip;
+ let roughness = mip as f32 / (mip_count - 1) as f32;
+
+ let bytes_per_pixel = 8usize; // 4 x f16
+ let unpadded_row = mip_size as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize;
+ let padded_row = unpadded_row.div_ceil(align) * align;
+
+ for face in 0..6u32 {
+ // Compute all pixels in parallel
+ let pixel_colors: Vec<[f32; 3]> = (0..mip_size * mip_size)
+ .into_par_iter()
+ .map(|idx| {
+ let x = idx % mip_size;
+ let y = idx / mip_size;
+ let u = (x as f32 + 0.5) / mip_size as f32 * 2.0 - 1.0;
+ let v = (y as f32 + 0.5) / mip_size as f32 * 2.0 - 1.0;
+
+ let n = normalize(face_uv_to_direction(face, u, v));
+ prefilter_env_map(pixels, src_width, src_height, n, roughness, sample_count)
+ })
+ .collect();
+
+ // Write results to buffer
+ let mut data = vec![0u8; padded_row * mip_size as usize];
+ for (idx, color) in pixel_colors.iter().enumerate() {
+ let x = idx as u32 % mip_size;
+ let y = idx as u32 / mip_size;
+ let offset = (y as usize * padded_row) + (x as usize * bytes_per_pixel);
+
+ let r = half::f16::from_f32(color[0]);
+ let g = half::f16::from_f32(color[1]);
+ let b = half::f16::from_f32(color[2]);
+ let a = half::f16::from_f32(1.0);
+
+ data[offset..offset + 2].copy_from_slice(&r.to_le_bytes());
+ data[offset + 2..offset + 4].copy_from_slice(&g.to_le_bytes());
+ data[offset + 4..offset + 6].copy_from_slice(&b.to_le_bytes());
+ data[offset + 6..offset + 8].copy_from_slice(&a.to_le_bytes());
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: mip,
+ origin: wgpu::Origin3d {
+ x: 0,
+ y: 0,
+ z: face,
+ },
+ aspect: wgpu::TextureAspect::All,
+ },
+ &data,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_row as u32),
+ rows_per_image: Some(mip_size),
+ },
+ wgpu::Extent3d {
+ width: mip_size,
+ height: mip_size,
+ depth_or_array_layers: 1,
+ },
+ );
+ }
+ }
+
+ texture.create_view(&wgpu::TextureViewDescriptor {
+ label: Some("prefiltered_cubemap_view"),
+ dimension: Some(wgpu::TextureViewDimension::Cube),
+ ..Default::default()
+ })
+}
+
+/// Pre-filter the environment map for a given roughness using GGX importance sampling.
+fn prefilter_env_map(
+ pixels: &[image::Rgb<f32>],
+ src_width: u32,
+ src_height: u32,
+ n: [f32; 3],
+ roughness: f32,
+ sample_count: u32,
+) -> [f32; 3] {
+ // For roughness = 0, just sample the environment directly
+ if roughness < 0.001 {
+ return sample_equirect(pixels, src_width, src_height, n);
+ }
+
+ // Use N = V = R assumption for pre-filtering
+ let r = n;
+ let v = r;
+
+ let mut prefilt = [0.0f32; 3];
+ let mut total_weight = 0.0f32;
+
+ let alpha = roughness * roughness;
+
+ for i in 0..sample_count {
+ let (xi_x, xi_y) = hammersley(i, sample_count);
+ let h = importance_sample_ggx_world(xi_x, xi_y, n, alpha);
+
+ // Reflect V around H to get L
+ let v_dot_h = dot(v, h).max(0.0);
+ let l = [
+ 2.0 * v_dot_h * h[0] - v[0],
+ 2.0 * v_dot_h * h[1] - v[1],
+ 2.0 * v_dot_h * h[2] - v[2],
+ ];
+
+ let n_dot_l = dot(n, l);
+ if n_dot_l > 0.0 {
+ let color = sample_equirect(pixels, src_width, src_height, l);
+ prefilt[0] += color[0] * n_dot_l;
+ prefilt[1] += color[1] * n_dot_l;
+ prefilt[2] += color[2] * n_dot_l;
+ total_weight += n_dot_l;
+ }
+ }
+
+ if total_weight > 0.0 {
+ let inv = 1.0 / total_weight;
+ [prefilt[0] * inv, prefilt[1] * inv, prefilt[2] * inv]
+ } else {
+ [0.0, 0.0, 0.0]
+ }
+}
+
+/// Importance sample GGX and return half-vector in world space.
+fn importance_sample_ggx_world(xi_x: f32, xi_y: f32, n: [f32; 3], alpha: f32) -> [f32; 3] {
+ // Sample in tangent space
+ let h_tangent = importance_sample_ggx(xi_x, xi_y, alpha);
+
+ // Build tangent frame
+ let up = if n[1].abs() < 0.999 {
+ [0.0, 1.0, 0.0]
+ } else {
+ [1.0, 0.0, 0.0]
+ };
+ let tangent = normalize(cross(up, n));
+ let bitangent = cross(n, tangent);
+
+ // Transform to world space
+ normalize([
+ h_tangent[0] * tangent[0] + h_tangent[1] * bitangent[0] + h_tangent[2] * n[0],
+ h_tangent[0] * tangent[1] + h_tangent[1] * bitangent[1] + h_tangent[2] * n[1],
+ h_tangent[0] * tangent[2] + h_tangent[1] * bitangent[2] + h_tangent[2] * n[2],
+ ])
+}
diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs
@@ -0,0 +1,980 @@
+use glam::{Mat4, Vec2, Vec3};
+
+use crate::material::{MaterialUniform, make_material_gpudata};
+use crate::model::ModelData;
+use crate::model::Vertex;
+use std::collections::HashMap;
+use std::num::NonZeroU64;
+
+mod camera;
+mod ibl;
+mod material;
+mod model;
+mod texture;
+mod world;
+
+#[cfg(feature = "egui")]
+pub mod egui;
+
+pub use camera::{ArcballController, Camera, FlyController, ThirdPersonController};
+pub use model::{Aabb, Model};
+pub use world::{Node, NodeId, ObjectId, Transform, World};
+
+/// Active camera controller mode.
+pub enum CameraMode {
+ Fly(camera::FlyController),
+ ThirdPerson(camera::ThirdPersonController),
+}
+
+#[repr(C)]
+#[derive(Debug, Copy, Clone, bytemuck::NoUninit, bytemuck::Zeroable)]
+struct ObjectUniform {
+ model: Mat4,
+ normal: Mat4, // inverse-transpose(model)
+}
+
+impl ObjectUniform {
+ fn from_model(model: Mat4) -> Self {
+ Self {
+ model,
+ normal: model.inverse().transpose(),
+ }
+ }
+}
+
+const MAX_SCENE_OBJECTS: usize = 256;
+
+struct DynamicObjectBuffer {
+ buffer: wgpu::Buffer,
+ bindgroup: wgpu::BindGroup,
+ stride: u64,
+}
+
+#[repr(C)]
+#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
+struct Globals {
+ // 0..16
+ time: f32,
+ _pad0: f32,
+ resolution: Vec2, // 8 bytes, finishes first 16-byte slot
+
+ // 16..32
+ cam_pos: Vec3, // takes 12, but aligned to 16
+ _pad3: f32, // fills the last 4 bytes of this 16-byte slot nicely
+
+ // 32..48
+ light_dir: Vec3,
+ _pad1: f32,
+
+ // 48..64
+ light_color: Vec3,
+ _pad2: f32,
+
+ // 64..80
+ fill_light_dir: Vec3,
+ _pad4: f32,
+
+ // 80..96
+ fill_light_color: Vec3,
+ _pad5: f32,
+
+ // 96..160
+ view_proj: Mat4,
+
+ // 160..224
+ inv_view_proj: Mat4,
+
+ // 224..288
+ light_view_proj: Mat4,
+}
+
+impl Globals {
+ fn set_camera(&mut self, w: f32, h: f32, camera: &Camera) {
+ self.cam_pos = camera.eye;
+ self.view_proj = camera.view_proj(w, h);
+ self.inv_view_proj = self.view_proj.inverse();
+ }
+}
+
+struct GpuData<R> {
+ data: R,
+ buffer: wgpu::Buffer,
+ bindgroup: wgpu::BindGroup,
+}
+
+const SHADOW_MAP_SIZE: u32 = 2048;
+
+pub struct Renderer {
+ size: (u32, u32),
+
+ /// To propery resize we need a device. Provide a target size so
+ /// we can dynamically resize next time get one.
+ target_size: (u32, u32),
+
+ model_ids: u64,
+
+ depth_tex: wgpu::Texture,
+ depth_view: wgpu::TextureView,
+ pipeline: wgpu::RenderPipeline,
+ skybox_pipeline: wgpu::RenderPipeline,
+ grid_pipeline: wgpu::RenderPipeline,
+ shadow_pipeline: wgpu::RenderPipeline,
+
+ shadow_tex: wgpu::Texture,
+ shadow_view: wgpu::TextureView,
+ shadow_sampler: wgpu::Sampler,
+ shadow_globals_bg: wgpu::BindGroup,
+
+ world: World,
+ camera_mode: CameraMode,
+
+ globals: GpuData<Globals>,
+ globals_bgl: wgpu::BindGroupLayout,
+ object_buf: DynamicObjectBuffer,
+ material: GpuData<MaterialUniform>,
+
+ material_bgl: wgpu::BindGroupLayout,
+
+ ibl: ibl::IblData,
+
+ models: HashMap<Model, ModelData>,
+
+ start: std::time::Instant,
+}
+
+fn make_globals_bgl(device: &wgpu::Device) -> wgpu::BindGroupLayout {
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("globals_bgl"),
+ entries: &[
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ sample_type: wgpu::TextureSampleType::Depth,
+ view_dimension: wgpu::TextureViewDimension::D2,
+ multisampled: false,
+ },
+ count: None,
+ },
+ wgpu::BindGroupLayoutEntry {
+ binding: 2,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
+ count: None,
+ },
+ ],
+ })
+}
+
+fn make_globals_bindgroup(
+ device: &wgpu::Device,
+ layout: &wgpu::BindGroupLayout,
+ globals_buf: &wgpu::Buffer,
+ shadow_view: &wgpu::TextureView,
+ shadow_sampler: &wgpu::Sampler,
+) -> wgpu::BindGroup {
+ device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("globals_bg"),
+ layout,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: globals_buf.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::TextureView(shadow_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::Sampler(shadow_sampler),
+ },
+ ],
+ })
+}
+
+fn make_global_gpudata(
+ device: &wgpu::Device,
+ width: f32,
+ height: f32,
+ camera: &Camera,
+ globals_bgl: &wgpu::BindGroupLayout,
+ shadow_view: &wgpu::TextureView,
+ shadow_sampler: &wgpu::Sampler,
+) -> GpuData<Globals> {
+ let view_proj = camera.view_proj(width, height);
+ let globals = Globals {
+ time: 0.0,
+ _pad0: 0.0,
+ resolution: Vec2::new(width, height),
+ cam_pos: camera.eye,
+ _pad3: 0.0,
+ // Key light: warm, from upper right (direction of light rays)
+ light_dir: Vec3::new(-0.5, -0.7, -0.3),
+ _pad1: 0.0,
+ light_color: Vec3::new(1.0, 0.98, 0.92),
+ _pad2: 0.0,
+ // Fill light: cooler, from lower left (opposite side)
+ fill_light_dir: Vec3::new(-0.7, -0.3, -0.5),
+ _pad4: 0.0,
+ fill_light_color: Vec3::new(0.5, 0.55, 0.6),
+ _pad5: 0.0,
+ view_proj,
+ inv_view_proj: view_proj.inverse(),
+ light_view_proj: Mat4::IDENTITY,
+ };
+
+ println!("Globals size = {}", std::mem::size_of::<Globals>());
+
+ let globals_buf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("globals"),
+ size: std::mem::size_of::<Globals>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ let globals_bg = make_globals_bindgroup(
+ device,
+ globals_bgl,
+ &globals_buf,
+ shadow_view,
+ shadow_sampler,
+ );
+
+ GpuData::<Globals> {
+ data: globals,
+ buffer: globals_buf,
+ bindgroup: globals_bg,
+ }
+}
+
+fn make_dynamic_object_buffer(
+ device: &wgpu::Device,
+) -> (DynamicObjectBuffer, wgpu::BindGroupLayout) {
+ // Alignment for dynamic uniform buffer offsets (typically 256)
+ let align = device.limits().min_uniform_buffer_offset_alignment as u64;
+ let obj_size = std::mem::size_of::<ObjectUniform>() as u64;
+ let stride = ((obj_size + align - 1) / align) * align;
+ let total_size = stride * MAX_SCENE_OBJECTS as u64;
+
+ let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("object_dynamic"),
+ size: total_size,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ let object_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("object_bgl"),
+ entries: &[wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::VERTEX,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: true,
+ min_binding_size: NonZeroU64::new(obj_size),
+ },
+ count: None,
+ }],
+ });
+
+ let bindgroup = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("object_dynamic_bg"),
+ layout: &object_bgl,
+ entries: &[wgpu::BindGroupEntry {
+ binding: 0,
+ resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
+ buffer: &buffer,
+ offset: 0,
+ size: NonZeroU64::new(obj_size),
+ }),
+ }],
+ });
+
+ (
+ DynamicObjectBuffer {
+ buffer,
+ bindgroup,
+ stride,
+ },
+ object_bgl,
+ )
+}
+
+impl Renderer {
+ pub fn new(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ format: wgpu::TextureFormat,
+ size: (u32, u32),
+ ) -> Self {
+ let (width, height) = size;
+
+ let eye = Vec3::new(0.0, 16.0, 24.0);
+ let target = Vec3::new(0.0, 0.0, 0.0);
+ let camera = Camera::new(eye, target);
+
+ let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("shader"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
+ });
+
+ let (shadow_tex, shadow_view, shadow_sampler) = create_shadow_map(device);
+ let globals_bgl = make_globals_bgl(device);
+ let globals = make_global_gpudata(
+ device,
+ width as f32,
+ height as f32,
+ &camera,
+ &globals_bgl,
+ &shadow_view,
+ &shadow_sampler,
+ );
+ let (object_buf, object_bgl) = make_dynamic_object_buffer(device);
+ let (material, material_bgl) = make_material_gpudata(device, queue);
+
+ let ibl_bgl = ibl::create_ibl_bind_group_layout(device);
+ let ibl = ibl::load_hdr_ibl_from_bytes(
+ device,
+ queue,
+ &ibl_bgl,
+ include_bytes!("../assets/venice_sunset_1k.hdr"),
+ )
+ .expect("failed to load HDR environment map");
+
+ let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("pipeline_layout"),
+ bind_group_layouts: &[&globals_bgl, &object_bgl, &material_bgl, &ibl_bgl],
+ push_constant_ranges: &[],
+ });
+
+ /*
+ let pipeline_cache = unsafe {
+ device.create_pipeline_cache(&wgpu::PipelineCacheDescriptor {
+ label: Some("pipeline_cache"),
+ data: None,
+ fallback: true,
+ })
+ };
+ */
+ let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("pipeline"),
+ //cache: Some(&pipeline_cache),
+ cache: None,
+ layout: Some(&pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("vs_main"),
+ buffers: &[Vertex::desc()],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("fs_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: Some(wgpu::BlendState::REPLACE),
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: wgpu::TextureFormat::Depth24Plus,
+ depth_write_enabled: true,
+ depth_compare: wgpu::CompareFunction::Less,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState::default(),
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ multiview: None,
+ });
+
+ // Skybox pipeline
+ let skybox_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("skybox_shader"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("skybox.wgsl").into()),
+ });
+
+ let skybox_pipeline_layout =
+ device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("skybox_pipeline_layout"),
+ bind_group_layouts: &[&globals_bgl, &object_bgl, &material_bgl, &ibl_bgl],
+ push_constant_ranges: &[],
+ });
+
+ let skybox_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("skybox_pipeline"),
+ cache: None,
+ layout: Some(&skybox_pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &skybox_shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("vs_main"),
+ buffers: &[], // No vertex buffers - procedural fullscreen triangle
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &skybox_shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("fs_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: Some(wgpu::BlendState::REPLACE),
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: wgpu::TextureFormat::Depth24Plus,
+ depth_write_enabled: true,
+ depth_compare: wgpu::CompareFunction::LessEqual,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState::default(),
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ multiview: None,
+ });
+
+ // Grid pipeline (infinite ground plane)
+ let grid_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("grid_shader"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("grid.wgsl").into()),
+ });
+
+ let grid_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("grid_pipeline_layout"),
+ bind_group_layouts: &[&globals_bgl, &object_bgl, &material_bgl, &ibl_bgl],
+ push_constant_ranges: &[],
+ });
+
+ let grid_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("grid_pipeline"),
+ cache: None,
+ layout: Some(&grid_pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &grid_shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("vs_main"),
+ buffers: &[],
+ },
+ fragment: Some(wgpu::FragmentState {
+ module: &grid_shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("fs_main"),
+ targets: &[Some(wgpu::ColorTargetState {
+ format,
+ blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+ write_mask: wgpu::ColorWrites::ALL,
+ })],
+ }),
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: wgpu::TextureFormat::Depth24Plus,
+ depth_write_enabled: true,
+ depth_compare: wgpu::CompareFunction::Less,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState::default(),
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ multiview: None,
+ });
+
+ // Shadow depth pipeline (depth-only, no fragment stage)
+ // Uses a separate globals BGL without the shadow texture to avoid
+ // the resource conflict (shadow tex as both attachment and binding).
+ let shadow_globals_bgl =
+ device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("shadow_globals_bgl"),
+ entries: &[wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ }],
+ });
+
+ let shadow_globals_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("shadow_globals_bg"),
+ layout: &shadow_globals_bgl,
+ entries: &[wgpu::BindGroupEntry {
+ binding: 0,
+ resource: globals.buffer.as_entire_binding(),
+ }],
+ });
+
+ let shadow_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+ label: Some("shadow_shader"),
+ source: wgpu::ShaderSource::Wgsl(include_str!("shadow.wgsl").into()),
+ });
+
+ let shadow_pipeline_layout =
+ device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+ label: Some("shadow_pipeline_layout"),
+ bind_group_layouts: &[&shadow_globals_bgl, &object_bgl],
+ push_constant_ranges: &[],
+ });
+
+ let shadow_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+ label: Some("shadow_pipeline"),
+ cache: None,
+ layout: Some(&shadow_pipeline_layout),
+ vertex: wgpu::VertexState {
+ module: &shadow_shader,
+ compilation_options: wgpu::PipelineCompilationOptions::default(),
+ entry_point: Some("vs_main"),
+ buffers: &[Vertex::desc()],
+ },
+ fragment: None, // depth-only pass
+ primitive: wgpu::PrimitiveState {
+ topology: wgpu::PrimitiveTopology::TriangleList,
+ ..Default::default()
+ },
+ depth_stencil: Some(wgpu::DepthStencilState {
+ format: wgpu::TextureFormat::Depth32Float,
+ depth_write_enabled: true,
+ depth_compare: wgpu::CompareFunction::Less,
+ stencil: wgpu::StencilState::default(),
+ bias: wgpu::DepthBiasState {
+ constant: 2,
+ slope_scale: 2.0,
+ clamp: 0.0,
+ },
+ }),
+ multisample: wgpu::MultisampleState::default(),
+ multiview: None,
+ });
+
+ let (depth_tex, depth_view) = create_depth(device, width, height);
+
+ /* TODO: move to example
+ let model = load_gltf_model(
+ &device,
+ &queue,
+ &material_bgl,
+ "/home/jb55/var/models/ironwood/ironwood.glb",
+ )
+ .unwrap();
+ */
+
+ let model_ids = 0;
+
+ let world = World::new(camera);
+
+ let camera_mode = CameraMode::Fly(camera::FlyController::from_camera(&world.camera));
+
+ Self {
+ world,
+ camera_mode,
+ target_size: size,
+ model_ids,
+ size,
+ pipeline,
+ skybox_pipeline,
+ grid_pipeline,
+ shadow_pipeline,
+ shadow_tex,
+ shadow_view,
+ shadow_sampler,
+ shadow_globals_bg,
+ globals,
+ globals_bgl,
+ object_buf,
+ material,
+ material_bgl,
+ ibl,
+ models: HashMap::new(),
+ depth_tex,
+ depth_view,
+ start: std::time::Instant::now(),
+ }
+ }
+
+ pub fn size(&self) -> (u32, u32) {
+ self.size
+ }
+
+ fn globals_mut(&mut self) -> &mut Globals {
+ &mut self.globals.data
+ }
+
+ /// Load a glTF model from disk. Returns a handle that can be placed in
+ /// the scene with [`place_object`].
+ pub fn load_gltf_model(
+ &mut self,
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ path: impl AsRef<std::path::Path>,
+ ) -> Result<Model, gltf::Error> {
+ let model_data = crate::model::load_gltf_model(device, queue, &self.material_bgl, path)?;
+
+ self.model_ids += 1;
+ let id = Model { id: self.model_ids };
+
+ self.models.insert(id, model_data);
+
+ Ok(id)
+ }
+
+ /// Place a loaded model in the scene with the given transform.
+ pub fn place_object(&mut self, model: Model, transform: Transform) -> ObjectId {
+ self.world.add_object(model, transform)
+ }
+
+ /// Remove an object from the scene.
+ pub fn remove_object(&mut self, id: ObjectId) -> bool {
+ self.world.remove_object(id)
+ }
+
+ /// Update the transform of a placed object.
+ pub fn update_object_transform(&mut self, id: ObjectId, transform: Transform) -> bool {
+ self.world.update_transform(id, transform)
+ }
+
+ /// Perform a resize if the target size is not the same as size
+ pub fn set_target_size(&mut self, size: (u32, u32)) {
+ self.target_size = size;
+ }
+
+ pub fn resize(&mut self, device: &wgpu::Device) {
+ if self.target_size == self.size {
+ return;
+ }
+
+ let (width, height) = self.target_size;
+ let w = width as f32;
+ let h = height as f32;
+
+ self.size = self.target_size;
+
+ self.globals.data.resolution = Vec2::new(w, h);
+ self.globals.data.set_camera(w, h, &self.world.camera);
+
+ let (depth_tex, depth_view) = create_depth(device, width, height);
+ self.depth_tex = depth_tex;
+ self.depth_view = depth_view;
+ }
+
+ pub fn focus_model(&mut self, model: Model) {
+ let Some(md) = self.models.get(&model) else {
+ return;
+ };
+
+ let (w, h) = self.size;
+ let w = w as f32;
+ let h = h as f32;
+
+ let aspect = w / h.max(1.0);
+
+ self.world.camera = Camera::fit_to_aabb(
+ md.bounds.min,
+ md.bounds.max,
+ aspect,
+ 45_f32.to_radians(),
+ 1.2,
+ );
+
+ // Sync controller to new camera position
+ self.camera_mode = CameraMode::Fly(camera::FlyController::from_camera(&self.world.camera));
+
+ self.globals.data.set_camera(w, h, &self.world.camera);
+ }
+
+ /// Get the axis-aligned bounding box for a loaded model.
+ pub fn model_bounds(&self, model: Model) -> Option<Aabb> {
+ self.models.get(&model).map(|md| md.bounds)
+ }
+
+ /// Handle mouse drag for camera look/orbit.
+ pub fn on_mouse_drag(&mut self, delta_x: f32, delta_y: f32) {
+ match &mut self.camera_mode {
+ CameraMode::Fly(fly) => fly.on_mouse_look(delta_x, delta_y),
+ CameraMode::ThirdPerson(tp) => tp.on_mouse_look(delta_x, delta_y),
+ }
+ }
+
+ /// Handle scroll for camera speed/zoom.
+ pub fn on_scroll(&mut self, delta: f32) {
+ match &mut self.camera_mode {
+ CameraMode::Fly(fly) => fly.on_scroll(delta),
+ CameraMode::ThirdPerson(tp) => tp.on_scroll(delta),
+ }
+ }
+
+ /// Move the camera or avatar. forward/right/up are signed.
+ pub fn process_movement(&mut self, forward: f32, right: f32, up: f32, dt: f32) {
+ match &mut self.camera_mode {
+ CameraMode::Fly(fly) => fly.process_movement(forward, right, up, dt),
+ CameraMode::ThirdPerson(tp) => tp.process_movement(forward, right, up, dt),
+ }
+ }
+
+ /// Switch to third-person camera mode with avatar at the given position.
+ pub fn set_third_person_mode(&mut self, avatar_position: Vec3) {
+ let mut tp = camera::ThirdPersonController::from_camera(&self.world.camera);
+ tp.avatar_position = avatar_position;
+ self.camera_mode = CameraMode::ThirdPerson(tp);
+ }
+
+ /// Switch to fly camera mode.
+ pub fn set_fly_mode(&mut self) {
+ self.camera_mode = CameraMode::Fly(camera::FlyController::from_camera(&self.world.camera));
+ }
+
+ /// Get the avatar position (None if not in third-person mode).
+ pub fn avatar_position(&self) -> Option<Vec3> {
+ match &self.camera_mode {
+ CameraMode::ThirdPerson(tp) => Some(tp.avatar_position),
+ _ => None,
+ }
+ }
+
+ /// Get the avatar yaw (None if not in third-person mode).
+ pub fn avatar_yaw(&self) -> Option<f32> {
+ match &self.camera_mode {
+ CameraMode::ThirdPerson(tp) => Some(tp.avatar_yaw),
+ _ => None,
+ }
+ }
+
+ pub fn update(&mut self) {
+ self.globals_mut().time = self.start.elapsed().as_secs_f32();
+
+ // Update camera from active controller
+ match &self.camera_mode {
+ CameraMode::Fly(fly) => fly.update_camera(&mut self.world.camera),
+ CameraMode::ThirdPerson(tp) => tp.update_camera(&mut self.world.camera),
+ }
+ let (w, h) = self.size;
+ self.globals
+ .data
+ .set_camera(w as f32, h as f32, &self.world.camera);
+
+ //let t = self.globals_mut().time * 0.3;
+ //self.globals_mut().light_dir = Vec3::new(t_slow.cos() * 0.6, 0.7, t_slow.sin() * 0.6);
+
+ // Recompute dirty world transforms before rendering
+ self.world.update_world_transforms();
+
+ // Compute light space matrix for shadow mapping
+ let light_dir = self.globals.data.light_dir.normalize();
+ let light_pos = -light_dir * 30.0; // Position light 30m back along its direction
+ let light_view = Mat4::look_at_rh(light_pos, Vec3::ZERO, Vec3::Y);
+ let extent = 15.0; // 30m x 30m ortho frustum
+ let light_proj = Mat4::orthographic_rh(-extent, extent, -extent, extent, 0.1, 80.0);
+ self.globals.data.light_view_proj = light_proj * light_view;
+ }
+
+ pub fn prepare(&self, queue: &wgpu::Queue) {
+ write_gpu_data(queue, &self.globals);
+
+ // Write per-object transforms into the dynamic buffer
+ for (i, &node_id) in self.world.renderables().iter().enumerate() {
+ let node = self.world.get_node(node_id).unwrap();
+ let obj_uniform = ObjectUniform::from_model(node.world_matrix());
+ let offset = i as u64 * self.object_buf.stride;
+ queue.write_buffer(
+ &self.object_buf.buffer,
+ offset,
+ bytemuck::bytes_of(&obj_uniform),
+ );
+ }
+ }
+
+ /// Record the shadow depth pass onto the given command encoder.
+ /// Must be called before the main render pass.
+ pub fn render_shadow(&self, encoder: &mut wgpu::CommandEncoder) {
+ let mut shadow_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("shadow_pass"),
+ color_attachments: &[],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &self.shadow_view,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(1.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ occlusion_query_set: None,
+ timestamp_writes: None,
+ });
+
+ shadow_pass.set_pipeline(&self.shadow_pipeline);
+ shadow_pass.set_bind_group(0, &self.shadow_globals_bg, &[]);
+
+ for (i, &node_id) in self.world.renderables().iter().enumerate() {
+ let node = self.world.get_node(node_id).unwrap();
+ let model_handle = node.model.unwrap();
+ let Some(model_data) = self.models.get(&model_handle) else {
+ continue;
+ };
+ let dynamic_offset = (i as u64 * self.object_buf.stride) as u32;
+ shadow_pass.set_bind_group(1, &self.object_buf.bindgroup, &[dynamic_offset]);
+
+ for d in &model_data.draws {
+ shadow_pass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..));
+ shadow_pass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16);
+ shadow_pass.draw_indexed(0..d.mesh.num_indices, 0, 0..1);
+ }
+ }
+ }
+
+ pub fn render(&self, frame: &wgpu::TextureView, encoder: &mut wgpu::CommandEncoder) {
+ self.render_shadow(encoder);
+
+ // Main render pass
+ let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+ label: Some("rpass"),
+ color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+ view: frame,
+ resolve_target: None,
+ ops: wgpu::Operations {
+ load: wgpu::LoadOp::Clear(wgpu::Color {
+ r: 0.00,
+ g: 0.00,
+ b: 0.00,
+ a: 1.0,
+ }),
+ store: wgpu::StoreOp::Store,
+ },
+ })],
+ depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
+ view: &self.depth_view,
+ depth_ops: Some(wgpu::Operations {
+ load: wgpu::LoadOp::Clear(1.0),
+ store: wgpu::StoreOp::Store,
+ }),
+ stencil_ops: None,
+ }),
+ occlusion_query_set: None,
+ timestamp_writes: None,
+ });
+
+ self.render_pass(&mut rpass);
+ }
+
+ pub fn render_pass(&self, rpass: &mut wgpu::RenderPass<'_>) {
+ // 1. Draw skybox first (writes depth=1.0)
+ rpass.set_pipeline(&self.skybox_pipeline);
+ rpass.set_bind_group(0, &self.globals.bindgroup, &[]);
+ rpass.set_bind_group(1, &self.object_buf.bindgroup, &[0]); // dynamic offset 0
+ rpass.set_bind_group(2, &self.material.bindgroup, &[]); // unused but required by layout
+ rpass.set_bind_group(3, &self.ibl.bindgroup, &[]);
+ rpass.draw(0..3, 0..1);
+
+ // 2. Draw ground grid (alpha-blended over skybox, writes depth)
+ rpass.set_pipeline(&self.grid_pipeline);
+ rpass.set_bind_group(0, &self.globals.bindgroup, &[]);
+ rpass.set_bind_group(1, &self.object_buf.bindgroup, &[0]);
+ rpass.set_bind_group(2, &self.material.bindgroup, &[]);
+ rpass.set_bind_group(3, &self.ibl.bindgroup, &[]);
+ rpass.draw(0..3, 0..1);
+
+ // 3. Draw all scene objects
+ rpass.set_pipeline(&self.pipeline);
+ rpass.set_bind_group(0, &self.globals.bindgroup, &[]);
+ rpass.set_bind_group(3, &self.ibl.bindgroup, &[]);
+
+ for (i, &node_id) in self.world.renderables().iter().enumerate() {
+ let node = self.world.get_node(node_id).unwrap();
+ let model_handle = node.model.unwrap();
+ let Some(model_data) = self.models.get(&model_handle) else {
+ continue;
+ };
+
+ let dynamic_offset = (i as u64 * self.object_buf.stride) as u32;
+ rpass.set_bind_group(1, &self.object_buf.bindgroup, &[dynamic_offset]);
+
+ for d in &model_data.draws {
+ rpass.set_bind_group(2, &model_data.materials[d.material_index].bindgroup, &[]);
+ rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..));
+ rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16);
+ rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1);
+ }
+ }
+ }
+}
+
+fn write_gpu_data<R: bytemuck::NoUninit>(queue: &wgpu::Queue, state: &GpuData<R>) {
+ //state.staging.clear();
+ //let mut storage = encase::UniformBuffer::new(&mut state.staging);
+ //storage.write(&state.data).unwrap();
+ queue.write_buffer(&state.buffer, 0, bytemuck::bytes_of(&state.data));
+}
+
+fn create_depth(
+ device: &wgpu::Device,
+ width: u32,
+ height: u32,
+) -> (wgpu::Texture, wgpu::TextureView) {
+ assert!(width < 8192);
+ assert!(height < 8192);
+ let size = wgpu::Extent3d {
+ width,
+ height,
+ depth_or_array_layers: 1,
+ };
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("depth"),
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Depth24Plus,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
+ view_formats: &[],
+ });
+ let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+ (tex, view)
+}
+
+fn create_shadow_map(device: &wgpu::Device) -> (wgpu::Texture, wgpu::TextureView, wgpu::Sampler) {
+ let size = wgpu::Extent3d {
+ width: SHADOW_MAP_SIZE,
+ height: SHADOW_MAP_SIZE,
+ depth_or_array_layers: 1,
+ };
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some("shadow_map"),
+ size,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format: wgpu::TextureFormat::Depth32Float,
+ usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
+ view_formats: &[],
+ });
+ let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
+ let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("shadow_sampler"),
+ address_mode_u: wgpu::AddressMode::ClampToEdge,
+ address_mode_v: wgpu::AddressMode::ClampToEdge,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ compare: Some(wgpu::CompareFunction::Less),
+ ..Default::default()
+ });
+ (tex, view, sampler)
+}
diff --git a/crates/renderbud/src/material.rs b/crates/renderbud/src/material.rs
@@ -0,0 +1,141 @@
+use crate::GpuData;
+use crate::texture::{make_1x1_rgba8, texture_layout_entry};
+use glam::Vec4;
+
+#[repr(C)]
+#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct MaterialUniform {
+ pub base_color_factor: glam::Vec4, // rgba
+ pub metallic_factor: f32,
+ pub roughness_factor: f32,
+ pub ao_strength: f32,
+ pub _pad0: f32,
+}
+
+pub struct MaterialGpu {
+ pub uniform: MaterialUniform,
+ pub buffer: wgpu::Buffer,
+ pub bindgroup: wgpu::BindGroup,
+}
+
+pub fn make_material_gpudata(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+) -> (GpuData<MaterialUniform>, wgpu::BindGroupLayout) {
+ let material_uniform = MaterialUniform {
+ base_color_factor: Vec4::new(1.0, 0.1, 0.1, 1.0),
+ metallic_factor: 1.0,
+ roughness_factor: 0.1,
+ ao_strength: 1.0,
+ _pad0: 0.0,
+ };
+
+ let material_buf = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("material"),
+ size: std::mem::size_of::<MaterialUniform>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ let material_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("material_sampler"),
+ address_mode_u: wgpu::AddressMode::Repeat,
+ address_mode_v: wgpu::AddressMode::Repeat,
+ address_mode_w: wgpu::AddressMode::Repeat,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ mipmap_filter: wgpu::FilterMode::Linear,
+ ..Default::default()
+ });
+
+ // Default textures
+ let basecolor_view = make_1x1_rgba8(
+ device,
+ queue,
+ wgpu::TextureFormat::Rgba8UnormSrgb,
+ [255, 255, 255, 255],
+ "basecolor_1x1",
+ );
+
+ let mr_view = make_1x1_rgba8(
+ device,
+ queue,
+ wgpu::TextureFormat::Rgba8Unorm,
+ [0, 255, 0, 255], // G=roughness=1, B=metallic=0 (A unused)
+ "mr_1x1",
+ );
+
+ let normal_view = make_1x1_rgba8(
+ device,
+ queue,
+ wgpu::TextureFormat::Rgba8Unorm,
+ [128, 128, 255, 255],
+ "normal_1x1",
+ );
+
+ let material_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
+ label: Some("material_bgl"),
+ entries: &[
+ // uniform
+ wgpu::BindGroupLayoutEntry {
+ binding: 0,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Buffer {
+ ty: wgpu::BufferBindingType::Uniform,
+ has_dynamic_offset: false,
+ min_binding_size: None,
+ },
+ count: None,
+ },
+ // sampler
+ wgpu::BindGroupLayoutEntry {
+ binding: 1,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
+ count: None,
+ },
+ // baseColor (sRGB)
+ texture_layout_entry(2),
+ // metallicRougness (linear)
+ texture_layout_entry(3),
+ // normal (linear)
+ texture_layout_entry(4),
+ ],
+ });
+
+ let material_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("material_bg"),
+ layout: &material_bgl,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: material_buf.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(&material_sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(&basecolor_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(&mr_view),
+ },
+ wgpu::BindGroupEntry {
+ binding: 4,
+ resource: wgpu::BindingResource::TextureView(&normal_view),
+ },
+ ],
+ });
+
+ (
+ GpuData::<MaterialUniform> {
+ data: material_uniform,
+ buffer: material_buf,
+ bindgroup: material_bg,
+ },
+ material_bgl,
+ )
+}
diff --git a/crates/renderbud/src/model.rs b/crates/renderbud/src/model.rs
@@ -0,0 +1,617 @@
+use glam::{Vec3, Vec4};
+
+use crate::material::{MaterialGpu, MaterialUniform};
+use crate::texture::upload_rgba8_texture_2d;
+use std::collections::HashMap;
+use wgpu::util::DeviceExt;
+
+#[repr(C)]
+#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
+pub struct Vertex {
+ pub pos: [f32; 3],
+ pub normal: [f32; 3],
+ pub uv: [f32; 2],
+ pub tangent: [f32; 4],
+}
+
+pub struct Mesh {
+ pub num_indices: u32,
+ pub vert_buf: wgpu::Buffer,
+ pub ind_buf: wgpu::Buffer,
+}
+
+pub struct ModelDraw {
+ pub mesh: Mesh,
+ pub material_index: usize,
+}
+
+pub struct ModelData {
+ pub draws: Vec<ModelDraw>,
+ pub materials: Vec<MaterialGpu>,
+ pub bounds: Aabb,
+}
+
+/// A model handle
+#[derive(Debug, Eq, PartialEq, Ord, PartialOrd, Hash, Copy, Clone)]
+pub struct Model {
+ pub id: u64,
+}
+
+struct GltfWgpuCache {
+ samplers: Vec<Option<wgpu::Sampler>>,
+ tex_views: HashMap<(usize, bool), wgpu::TextureView>,
+}
+
+impl GltfWgpuCache {
+ pub fn new(doc: &gltf::Document) -> Self {
+ Self {
+ samplers: (0..doc.samplers().len()).map(|_| None).collect(),
+ tex_views: HashMap::new(),
+ }
+ }
+
+ fn ensure_sampler(
+ &mut self,
+ device: &wgpu::Device,
+ sam: gltf::texture::Sampler<'_>,
+ ) -> Option<usize> {
+ let idx = sam.index()?;
+ if self.samplers[idx].is_none() {
+ let (min_f, mip_f) = map_min_filter(sam.min_filter());
+ let samp = device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("gltf_sampler"),
+ address_mode_u: map_wrap_mode(sam.wrap_s()),
+ address_mode_v: map_wrap_mode(sam.wrap_t()),
+ address_mode_w: wgpu::AddressMode::Repeat,
+ mag_filter: map_mag_filter(sam.mag_filter()),
+ min_filter: min_f,
+ mipmap_filter: mip_f,
+ ..Default::default()
+ });
+ self.samplers[idx] = Some(samp);
+ }
+ Some(idx)
+ }
+
+ fn sampler_ref(&self, idx: usize) -> &wgpu::Sampler {
+ self.samplers[idx].as_ref().unwrap()
+ }
+
+ fn ensure_texture_view(
+ &mut self,
+ images: &[gltf::image::Data],
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ tex: gltf::Texture<'_>,
+ srgb: bool,
+ ) -> (usize, bool) {
+ let key = (tex.index(), srgb);
+ self.tex_views.entry(key).or_insert_with(|| {
+ let img = &images[tex.source().index()];
+ let rgba8 = build_rgba(img);
+
+ let format = if srgb {
+ wgpu::TextureFormat::Rgba8UnormSrgb
+ } else {
+ wgpu::TextureFormat::Rgba8Unorm
+ };
+
+ upload_rgba8_texture_2d(
+ device, queue, img.width, img.height, &rgba8, format, "gltf_tex",
+ )
+ });
+ key
+ }
+
+ fn view_ref(&self, key: (usize, bool)) -> &wgpu::TextureView {
+ self.tex_views.get(&key).unwrap()
+ }
+}
+
+impl Vertex {
+ pub fn desc<'a>() -> wgpu::VertexBufferLayout<'a> {
+ use std::mem;
+ wgpu::VertexBufferLayout {
+ array_stride: mem::size_of::<Vertex>() as wgpu::BufferAddress,
+ step_mode: wgpu::VertexStepMode::Vertex,
+ attributes: &[
+ // position
+ wgpu::VertexAttribute {
+ offset: 0,
+ shader_location: 0,
+ format: wgpu::VertexFormat::Float32x3,
+ },
+ // normal
+ wgpu::VertexAttribute {
+ offset: mem::size_of::<[f32; 3]>() as u64,
+ shader_location: 1,
+ format: wgpu::VertexFormat::Float32x3,
+ },
+ // uv
+ wgpu::VertexAttribute {
+ offset: (mem::size_of::<[f32; 3]>() + mem::size_of::<[f32; 3]>()) as u64,
+ shader_location: 2,
+ format: wgpu::VertexFormat::Float32x2,
+ },
+ // tangent
+ wgpu::VertexAttribute {
+ offset: (mem::size_of::<[f32; 3]>()
+ + mem::size_of::<[f32; 3]>()
+ + mem::size_of::<[f32; 2]>()) as u64, // 12+12+8 = 32
+ shader_location: 3,
+ format: wgpu::VertexFormat::Float32x4,
+ },
+ ],
+ }
+ }
+}
+
+fn build_rgba(img: &gltf::image::Data) -> Vec<u8> {
+ match img.format {
+ gltf::image::Format::R8 => img.pixels.iter().flat_map(|&r| [r, r, r, 255]).collect(),
+ gltf::image::Format::R8G8B8 => img
+ .pixels
+ .chunks_exact(3)
+ .flat_map(|p| [p[0], p[1], p[2], 255])
+ .collect(),
+ gltf::image::Format::R8G8B8A8 => img.pixels.clone(),
+ gltf::image::Format::R16 => {
+ // super rare for your target; quick & dirty downconvert
+ img.pixels
+ .chunks_exact(2)
+ .flat_map(|p| {
+ let r = p[0];
+ [r, r, r, 255]
+ })
+ .collect()
+ }
+ gltf::image::Format::R16G16B16 => img
+ .pixels
+ .chunks_exact(6)
+ .flat_map(|p| {
+ let r = p[0];
+ let g = p[2];
+ let b = p[4];
+ [r, g, b, 255]
+ })
+ .collect(),
+ gltf::image::Format::R16G16B16A16 => img
+ .pixels
+ .chunks_exact(8)
+ .flat_map(|p| {
+ let r = p[0];
+ let g = p[2];
+ let b = p[4];
+ let a = p[6];
+ [r, g, b, a]
+ })
+ .collect(),
+ _ => panic!("Unhandled image format {:?}", img.format),
+ }
+}
+
+pub fn load_gltf_model(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ material_bgl: &wgpu::BindGroupLayout,
+ path: impl AsRef<std::path::Path>,
+) -> Result<ModelData, gltf::Error> {
+ let path = path.as_ref();
+
+ let (doc, buffers, images) = gltf::import(path)?;
+
+ // --- default textures
+ let default_sampler = make_default_sampler(device);
+ let default_basecolor = upload_rgba8_texture_2d(
+ device,
+ queue,
+ 1,
+ 1,
+ &[255, 255, 255, 255],
+ wgpu::TextureFormat::Rgba8UnormSrgb,
+ "basecolor_1x1",
+ );
+ let default_mr = upload_rgba8_texture_2d(
+ device,
+ queue,
+ 1,
+ 1,
+ &[0, 255, 0, 255],
+ wgpu::TextureFormat::Rgba8Unorm,
+ "mr_1x1",
+ );
+ let default_normal = upload_rgba8_texture_2d(
+ device,
+ queue,
+ 1,
+ 1,
+ &[128, 128, 255, 255],
+ wgpu::TextureFormat::Rgba8Unorm,
+ "normal_1x1",
+ );
+
+ let mut cache = GltfWgpuCache::new(&doc);
+
+ let mut materials: Vec<MaterialGpu> = Vec::new();
+
+ for mat in doc.materials() {
+ let pbr = mat.pbr_metallic_roughness();
+ let bc_factor = pbr.base_color_factor();
+ let metallic_factor = pbr.metallic_factor();
+ let roughness_factor = pbr.roughness_factor();
+ let ao_strength = mat.occlusion_texture().map(|o| o.strength()).unwrap_or(1.0);
+
+ let mut chosen_sampler_idx: Option<usize> = None;
+
+ let basecolor_key = pbr.base_color_texture().map(|info| {
+ let s_idx = cache.ensure_sampler(device, info.texture().sampler());
+ if chosen_sampler_idx.is_none() {
+ chosen_sampler_idx = s_idx;
+ }
+ cache.ensure_texture_view(&images, device, queue, info.texture(), true)
+ });
+
+ let mr_key = pbr.metallic_roughness_texture().map(|info| {
+ let s_idx = cache.ensure_sampler(device, info.texture().sampler());
+ if chosen_sampler_idx.is_none() {
+ chosen_sampler_idx = s_idx;
+ }
+ cache.ensure_texture_view(&images, device, queue, info.texture(), false)
+ });
+
+ let normal_key = mat.normal_texture().map(|norm_tex| {
+ let s_idx = cache.ensure_sampler(device, norm_tex.texture().sampler());
+ if chosen_sampler_idx.is_none() {
+ chosen_sampler_idx = s_idx;
+ }
+ cache.ensure_texture_view(&images, device, queue, norm_tex.texture(), false)
+ });
+
+ let uniform = MaterialUniform {
+ base_color_factor: Vec4::new(bc_factor[0], bc_factor[1], bc_factor[2], bc_factor[3]),
+ metallic_factor,
+ roughness_factor,
+ ao_strength,
+ _pad0: 0.0,
+ };
+
+ let chosen_sampler: &wgpu::Sampler = chosen_sampler_idx
+ .map(|i| cache.sampler_ref(i))
+ .unwrap_or(&default_sampler);
+
+ let normal_view: &wgpu::TextureView = normal_key
+ .map(|k| cache.view_ref(k))
+ .unwrap_or(&default_normal);
+
+ let basecolor_view: &wgpu::TextureView = basecolor_key
+ .map(|k| cache.view_ref(k))
+ .unwrap_or(&default_basecolor);
+
+ let mr_view: &wgpu::TextureView = mr_key.map(|k| cache.view_ref(k)).unwrap_or(&default_mr);
+
+ materials.push(make_material_gpu(
+ device,
+ queue,
+ material_bgl,
+ chosen_sampler,
+ basecolor_view,
+ mr_view,
+ normal_view,
+ uniform,
+ ));
+ }
+
+ let default_material_index = materials.len();
+ materials.push(make_material_gpu(
+ device,
+ queue,
+ material_bgl,
+ &default_sampler,
+ &default_basecolor,
+ &default_mr,
+ &default_normal,
+ MaterialUniform {
+ base_color_factor: Vec4::ONE,
+ metallic_factor: 0.0,
+ roughness_factor: 1.0,
+ ao_strength: 1.0,
+ _pad0: 0.0,
+ },
+ ));
+
+ let mut draws: Vec<ModelDraw> = Vec::new();
+ let mut bounds = Aabb::empty();
+
+ for mesh in doc.meshes() {
+ for prim in mesh.primitives() {
+ if prim.mode() != gltf::mesh::Mode::Triangles {
+ continue;
+ }
+
+ let reader = prim.reader(|b| Some(&buffers[b.index()]));
+
+ let positions: Vec<[f32; 3]> = match reader.read_positions() {
+ Some(it) => it.collect(),
+ None => continue,
+ };
+
+ let normals: Vec<[f32; 3]> = reader
+ .read_normals()
+ .map(|it| it.collect())
+ .unwrap_or_else(|| vec![[0.0, 0.0, 1.0]; positions.len()]);
+
+ let uvs: Vec<[f32; 2]> = reader
+ .read_tex_coords(0)
+ .map(|tc| tc.into_f32().collect())
+ .unwrap_or_else(|| vec![[0.0, 0.0]; positions.len()]);
+
+ // TODO(jb55): switch to u32 indices
+ let indices: Vec<u16> = if let Some(read) = reader.read_indices() {
+ read.into_u32().map(|i| i as u16).collect()
+ } else {
+ (0..positions.len() as u16).collect()
+ };
+
+ /*
+ let tangents: Vec<[f32; 4]> = reader
+ .read_tangents()
+ .map(|it| it.collect())
+ .unwrap_or_else(|| vec![[1.0, 0.0, 0.0, 1.0]; positions.len()]);
+ */
+
+ let mut verts: Vec<Vertex> = Vec::with_capacity(positions.len());
+ for i in 0..positions.len() {
+ let pos = positions[i];
+ bounds.include_point(Vec3::new(pos[0], pos[1], pos[2]));
+
+ verts.push(Vertex {
+ pos: pos,
+ normal: normals[i],
+ uv: uvs[i],
+ tangent: [0.0, 0.0, 0.0, 0.0],
+ })
+ }
+
+ compute_tangents(&mut verts, &indices);
+
+ let vert_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("gltf_vert_buf"),
+ contents: bytemuck::cast_slice(&verts),
+ usage: wgpu::BufferUsages::VERTEX,
+ });
+
+ let ind_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
+ label: Some("gltf_ind_buf"),
+ contents: bytemuck::cast_slice(&indices),
+ usage: wgpu::BufferUsages::INDEX,
+ });
+
+ let material_index = prim.material().index().unwrap_or(default_material_index);
+
+ draws.push(ModelDraw {
+ mesh: Mesh {
+ num_indices: indices.len() as u32,
+ vert_buf,
+ ind_buf,
+ },
+ material_index,
+ });
+ }
+ }
+
+ Ok(ModelData {
+ draws,
+ materials,
+ bounds,
+ })
+}
+
+fn make_default_sampler(device: &wgpu::Device) -> wgpu::Sampler {
+ device.create_sampler(&wgpu::SamplerDescriptor {
+ label: Some("gltf_default_sampler"),
+ address_mode_u: wgpu::AddressMode::Repeat,
+ address_mode_v: wgpu::AddressMode::Repeat,
+ address_mode_w: wgpu::AddressMode::Repeat,
+ mag_filter: wgpu::FilterMode::Linear,
+ min_filter: wgpu::FilterMode::Linear,
+ mipmap_filter: wgpu::FilterMode::Nearest,
+ ..Default::default()
+ })
+}
+
+/// Keep wrap modes consistent when mapping glTF sampler -> wgpu sampler
+fn map_wrap_mode(wrap_mode: gltf::texture::WrappingMode) -> wgpu::AddressMode {
+ match wrap_mode {
+ gltf::texture::WrappingMode::ClampToEdge => wgpu::AddressMode::ClampToEdge,
+ gltf::texture::WrappingMode::MirroredRepeat => wgpu::AddressMode::MirrorRepeat,
+ gltf::texture::WrappingMode::Repeat => wgpu::AddressMode::Repeat,
+ }
+}
+
+fn map_min_filter(f: Option<gltf::texture::MinFilter>) -> (wgpu::FilterMode, wgpu::FilterMode) {
+ // (min, mipmap)
+ match f {
+ Some(gltf::texture::MinFilter::Nearest) => {
+ (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
+ }
+ Some(gltf::texture::MinFilter::Linear) => {
+ (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
+ }
+
+ Some(gltf::texture::MinFilter::NearestMipmapNearest) => {
+ (wgpu::FilterMode::Nearest, wgpu::FilterMode::Nearest)
+ }
+ Some(gltf::texture::MinFilter::LinearMipmapNearest) => {
+ (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest)
+ }
+ Some(gltf::texture::MinFilter::NearestMipmapLinear) => {
+ (wgpu::FilterMode::Nearest, wgpu::FilterMode::Linear)
+ }
+ Some(gltf::texture::MinFilter::LinearMipmapLinear) => {
+ (wgpu::FilterMode::Linear, wgpu::FilterMode::Linear)
+ }
+
+ None => (wgpu::FilterMode::Linear, wgpu::FilterMode::Nearest),
+ }
+}
+
+fn map_mag_filter(f: Option<gltf::texture::MagFilter>) -> wgpu::FilterMode {
+ match f {
+ Some(gltf::texture::MagFilter::Nearest) => wgpu::FilterMode::Nearest,
+ Some(gltf::texture::MagFilter::Linear) => wgpu::FilterMode::Linear,
+ None => wgpu::FilterMode::Linear,
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn make_material_gpu(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ material_bgl: &wgpu::BindGroupLayout,
+ sampler: &wgpu::Sampler,
+ basecolor: &wgpu::TextureView,
+ mr: &wgpu::TextureView,
+ normal: &wgpu::TextureView,
+ uniform: MaterialUniform,
+) -> MaterialGpu {
+ let buffer = device.create_buffer(&wgpu::BufferDescriptor {
+ label: Some("material_ubo"),
+ size: std::mem::size_of::<MaterialUniform>() as u64,
+ usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
+ mapped_at_creation: false,
+ });
+
+ // write uniform once
+ queue.write_buffer(&buffer, 0, bytemuck::bytes_of(&uniform));
+
+ let bindgroup = device.create_bind_group(&wgpu::BindGroupDescriptor {
+ label: Some("material_bg"),
+ layout: material_bgl,
+ entries: &[
+ wgpu::BindGroupEntry {
+ binding: 0,
+ resource: buffer.as_entire_binding(),
+ },
+ wgpu::BindGroupEntry {
+ binding: 1,
+ resource: wgpu::BindingResource::Sampler(sampler),
+ },
+ wgpu::BindGroupEntry {
+ binding: 2,
+ resource: wgpu::BindingResource::TextureView(basecolor),
+ },
+ wgpu::BindGroupEntry {
+ binding: 3,
+ resource: wgpu::BindingResource::TextureView(mr),
+ },
+ wgpu::BindGroupEntry {
+ binding: 4,
+ resource: wgpu::BindingResource::TextureView(normal),
+ },
+ ],
+ });
+
+ MaterialGpu {
+ uniform,
+ buffer,
+ bindgroup,
+ }
+}
+
+fn compute_tangents(verts: &mut [Vertex], indices: &[u16]) {
+ use glam::{Vec2, Vec3};
+
+ let n = verts.len();
+ let mut tan1 = vec![Vec3::ZERO; n];
+ let mut tan2 = vec![Vec3::ZERO; n];
+
+ let to_v3 = |a: [f32; 3]| Vec3::new(a[0], a[1], a[2]);
+ let to_v2 = |a: [f32; 2]| Vec2::new(a[0], a[1]);
+
+ // Accumulate per-triangle tangents/bitangents
+ for tri in indices.chunks_exact(3) {
+ let i0 = tri[0] as usize;
+ let i1 = tri[1] as usize;
+ let i2 = tri[2] as usize;
+
+ let p0 = to_v3(verts[i0].pos);
+ let p1 = to_v3(verts[i1].pos);
+ let p2 = to_v3(verts[i2].pos);
+
+ let w0 = to_v2(verts[i0].uv);
+ let w1 = to_v2(verts[i1].uv);
+ let w2 = to_v2(verts[i2].uv);
+
+ let e1 = p1 - p0;
+ let e2 = p2 - p0;
+
+ let d1 = w1 - w0;
+ let d2 = w2 - w0;
+
+ let denom = d1.x * d2.y - d1.y * d2.x;
+ if denom.abs() < 1e-8 {
+ continue; // degenerate UV mapping; skip
+ }
+ let r = 1.0 / denom;
+
+ let sdir = (e1 * d2.y - e2 * d1.y) * r; // tangent direction
+ let tdir = (e2 * d1.x - e1 * d2.x) * r; // bitangent direction
+
+ tan1[i0] += sdir;
+ tan1[i1] += sdir;
+ tan1[i2] += sdir;
+ tan2[i0] += tdir;
+ tan2[i1] += tdir;
+ tan2[i2] += tdir;
+ }
+
+ // Orthonormalize & store handedness in w
+ for i in 0..n {
+ let nrm = to_v3(verts[i].normal).normalize_or_zero();
+ let t = tan1[i];
+
+ // Gram–Schmidt: make T perpendicular to N
+ let t_ortho = (t - nrm * nrm.dot(t)).normalize_or_zero();
+
+ // Handedness: +1 or -1
+ let w = if nrm.cross(t_ortho).dot(tan2[i]) < 0.0 {
+ -1.0
+ } else {
+ 1.0
+ };
+
+ verts[i].tangent = [t_ortho.x, t_ortho.y, t_ortho.z, w];
+ }
+}
+
+#[derive(Debug, Copy, Clone)]
+pub struct Aabb {
+ pub min: Vec3,
+ pub max: Vec3,
+}
+
+impl Aabb {
+ pub fn empty() -> Self {
+ Self {
+ min: Vec3::splat(f32::INFINITY),
+ max: Vec3::splat(f32::NEG_INFINITY),
+ }
+ }
+
+ pub fn include_point(&mut self, p: Vec3) {
+ self.min = self.min.min(p);
+ self.max = self.max.max(p);
+ }
+
+ pub fn center(&self) -> Vec3 {
+ (self.min + self.max) * 0.5
+ }
+
+ pub fn half_extents(&self) -> Vec3 {
+ (self.max - self.min) * 0.5
+ }
+
+ pub fn radius(&self) -> f32 {
+ self.half_extents().length()
+ }
+}
diff --git a/crates/renderbud/src/shader.wgsl b/crates/renderbud/src/shader.wgsl
@@ -0,0 +1,299 @@
+const PI: f32 = 3.14159265;
+const EPS: f32 = 1e-4;
+
+const MIN_ROUGHNESS: f32 = 0.04;
+const INV_GAMMA: f32 = 1.0 / 2.2;
+
+struct Globals {
+ time: f32,
+ _pad0: f32,
+ resolution: vec2<f32>,
+
+ cam_pos: vec3<f32>,
+ _pad3: f32,
+
+ light_dir: vec3<f32>,
+ _pad1: f32,
+
+ light_color: vec3<f32>,
+ _pad2: f32,
+
+ fill_light_dir: vec3<f32>,
+ _pad4: f32,
+
+ fill_light_color: vec3<f32>,
+ _pad5: f32,
+
+ view_proj: mat4x4<f32>,
+ inv_view_proj: mat4x4<f32>,
+ light_view_proj: mat4x4<f32>,
+};
+
+@group(0) @binding(0)
+var<uniform> globals: Globals;
+@group(0) @binding(1) var shadow_map: texture_depth_2d;
+@group(0) @binding(2) var shadow_sampler: sampler_comparison;
+
+struct Object {
+ model: mat4x4<f32>,
+ normal: mat4x4<f32>,
+};
+
+@group(1) @binding(0)
+var<uniform> object: Object;
+
+struct Material {
+ base_color_factor: vec4<f32>,
+ metallic_factor: f32,
+ roughness_factor: f32,
+ ao_strength: f32,
+ _pad0: f32,
+};
+
+@group(2) @binding(0) var<uniform> material: Material;
+@group(2) @binding(1) var material_sampler: sampler;
+@group(2) @binding(2) var basecolor_tex: texture_2d<f32>;
+@group(2) @binding(3) var ao_mr_tex: texture_2d<f32>;
+@group(2) @binding(4) var normal_tex: texture_2d<f32>;
+
+// IBL
+@group(3) @binding(0) var irradiance_map: texture_cube<f32>;
+@group(3) @binding(1) var ibl_sampler: sampler;
+@group(3) @binding(2) var prefiltered_map: texture_cube<f32>;
+@group(3) @binding(3) var brdf_lut: texture_2d<f32>;
+
+struct VSIn {
+ @location(0) pos: vec3<f32>,
+ @location(1) normal: vec3<f32>,
+ @location(2) uv: vec2<f32>,
+ @location(3) tangent: vec4<f32>,
+};
+
+struct VSOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) world_pos: vec3<f32>,
+ @location(1) world_normal: vec3<f32>,
+ @location(2) uv: vec2<f32>,
+ @location(3) world_tangent: vec4<f32>, // xyz + w
+};
+
+
+@vertex
+fn vs_main(v: VSIn) -> VSOut {
+ var out: VSOut;
+
+ // For now: identity model matrix. Next step is per-object transforms.
+ let world4 = object.model * vec4<f32>(v.pos, 1.0);
+ out.world_pos = world4.xyz;
+
+ let n4 = object.normal * vec4<f32>(v.normal, 0.0);
+ let t4 = object.model * vec4<f32>(v.tangent.xyz, 0.0);
+
+ out.world_normal = normalize(n4.xyz);
+ out.uv = v.uv;
+ out.clip = globals.view_proj * world4;
+ out.world_tangent = vec4<f32>(normalize(t4.xyz), v.tangent.w);
+ return out;
+}
+
+fn saturate(x: f32) -> f32 { return clamp(x, 0.0, 1.0); }
+fn saturate3(v: vec3<f32>) -> vec3<f32> { return clamp(v, vec3<f32>(0.0), vec3<f32>(1.0)); }
+
+fn safe_normalize(v: vec3<f32>) -> vec3<f32> {
+ let l2 = dot(v, v);
+ if (l2 <= EPS) { return vec3<f32>(0.0, 0.0, 1.0); }
+ return v * inverseSqrt(l2);
+}
+
+// Fresnel (Schlick)
+fn fresnel_schlick(cosTheta: f32, F0: vec3<f32>) -> vec3<f32> {
+ let ct = saturate(cosTheta);
+ let f = pow(1.0 - ct, 5.0);
+ return F0 + (1.0 - F0) * f;
+}
+
+// Fresnel with roughness attenuation for IBL
+fn fresnel_schlick_roughness(cosTheta: f32, F0: vec3<f32>, roughness: f32) -> vec3<f32> {
+ let ct = saturate(cosTheta);
+ let f = pow(1.0 - ct, 5.0);
+ return F0 + (max(vec3<f32>(1.0 - roughness), F0) - F0) * f;
+}
+
+// Specular IBL using split-sum approximation
+fn specular_ibl(N: vec3<f32>, V: vec3<f32>, F0: vec3<f32>, roughness: f32) -> vec3<f32> {
+ let R = reflect(-V, N);
+
+ // Sample pre-filtered environment at roughness-based mip level
+ let MAX_REFLECTION_LOD = 4.0; // mip_count - 1
+ let prefiltered = textureSampleLevel(
+ prefiltered_map,
+ ibl_sampler,
+ R,
+ roughness * MAX_REFLECTION_LOD
+ ).rgb;
+
+ // Sample BRDF LUT
+ let NdotV = saturate(dot(N, V));
+ let brdf = textureSample(brdf_lut, ibl_sampler, vec2<f32>(NdotV, roughness)).rg;
+
+ // Combine using split-sum: prefiltered * (F0 * scale + bias)
+ return prefiltered * (F0 * brdf.x + brdf.y);
+}
+
+// GGX / Trowbridge-Reitz NDF
+fn ggx_ndf(NdotH: f32, alpha: f32) -> f32 {
+ let a2 = alpha * alpha;
+ let d = (NdotH * NdotH) * (a2 - 1.0) + 1.0;
+ return a2 / (PI * d * d);
+}
+
+// Smith geometry with Schlick-GGX (UE4 k)
+fn smith_g_schlick_ggx(NdotV: f32, NdotL: f32, alpha: f32) -> f32 {
+ let k = alpha + 1.0;
+ let k2 = (k * k) / 8.0;
+
+ let gv = NdotV / (NdotV * (1.0 - k2) + k2);
+ let gl = NdotL / (NdotL * (1.0 - k2) + k2);
+ return gv * gl;
+}
+
+fn diffuse_lambert(diffuseColor: vec3<f32>) -> vec3<f32> {
+ return diffuseColor / PI;
+}
+
+// RG normal map decode (optionally flips Y for your convention)
+fn decode_normal_rg(tex: vec3<f32>) -> vec3<f32> {
+ let x = tex.r * 2.0 - 1.0;
+ var y = tex.g * 2.0 - 1.0;
+ y = -y; // <- keep your current behavior here
+
+ let z2 = max(1.0 - x*x - y*y, 0.0);
+ let z = sqrt(z2);
+ return safe_normalize(vec3<f32>(x, y, z));
+}
+
+fn calc_shadow(world_pos: vec3<f32>) -> f32 {
+ let light_clip = globals.light_view_proj * vec4<f32>(world_pos, 1.0);
+ let ndc = light_clip.xyz / light_clip.w;
+
+ // Convert from NDC [-1,1] to UV [0,1], flip Y for texture coords
+ let shadow_uv = vec2<f32>(ndc.x * 0.5 + 0.5, -ndc.y * 0.5 + 0.5);
+
+ // Out-of-bounds = fully lit
+ if shadow_uv.x < 0.0 || shadow_uv.x > 1.0 || shadow_uv.y < 0.0 || shadow_uv.y > 1.0 {
+ return 1.0;
+ }
+
+ let ref_depth = ndc.z;
+
+ // 3x3 PCF for soft shadow edges
+ let texel_size = 1.0 / 2048.0;
+ var shadow = 0.0;
+ for (var y = -1i; y <= 1i; y++) {
+ for (var x = -1i; x <= 1i; x++) {
+ let offset = vec2<f32>(f32(x), f32(y)) * texel_size;
+ shadow += textureSampleCompareLevel(
+ shadow_map,
+ shadow_sampler,
+ shadow_uv + offset,
+ ref_depth,
+ );
+ }
+ }
+ return shadow / 9.0;
+}
+
+fn build_tbn(Ng: vec3<f32>, world_tangent: vec4<f32>) -> mat3x3<f32> {
+ var T = safe_normalize(world_tangent.xyz);
+ T = safe_normalize(T - Ng * dot(Ng, T));
+ let B = safe_normalize(cross(Ng, T)) * world_tangent.w;
+ return mat3x3<f32>(T, B, Ng);
+}
+
+@fragment
+fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
+ let Ng = safe_normalize(in.world_normal);
+ let tbn = build_tbn(Ng, in.world_tangent);
+
+ let n_ts = decode_normal_rg(textureSample(normal_tex, material_sampler, in.uv).xyz);
+ let N = safe_normalize(tbn * n_ts);
+
+ let V = safe_normalize(globals.cam_pos - in.world_pos);
+ let L = safe_normalize(-globals.light_dir);
+ let H = safe_normalize(V + L);
+
+ let NdotL = saturate(dot(N, L));
+ let NdotV = saturate(dot(N, V));
+ let NdotH = saturate(dot(N, H));
+ let VdotH = saturate(dot(V, H));
+
+ let bc_tex = textureSample(basecolor_tex, material_sampler, in.uv);
+ let ao_mr = textureSample(ao_mr_tex, material_sampler, in.uv);
+
+ // glTF metallicRoughnessTexture: G=roughness, B=metallic
+ let baseColor = bc_tex.rgb * material.base_color_factor.rgb;
+ let metallic = saturate(ao_mr.b * material.metallic_factor);
+ let rough_in = ao_mr.g * material.roughness_factor;
+
+ // AO: R channel; strength lerp from 1 -> ao
+ let ao_tex = ao_mr.r;
+ let ao = 1.0 + (ao_tex - 1.0) * saturate(material.ao_strength);
+ //let ao = 1.0;
+
+ let roughness = clamp(rough_in, MIN_ROUGHNESS, 1.0);
+ let alpha = roughness * roughness;
+
+ let F0 = mix(vec3<f32>(0.04), baseColor, metallic);
+ let diffuseColor = baseColor * (1.0 - metallic);
+
+ let D = ggx_ndf(NdotH, alpha);
+ let Gs = smith_g_schlick_ggx(NdotV, NdotL, alpha);
+ let F = fresnel_schlick(VdotH, F0);
+
+ let denom = max(4.0 * NdotV * NdotL, EPS);
+ let spec = (D * Gs) * F / denom;
+
+ let kd = (vec3<f32>(1.0) - F) * (1.0 - metallic);
+ let diff = kd * diffuse_lambert(diffuseColor);
+
+ let shadow = calc_shadow(in.world_pos);
+ let direct = (diff + spec) * (globals.light_color * NdotL) * shadow;
+
+ // Fill light contribution
+ let L2 = safe_normalize(-globals.fill_light_dir);
+ let H2 = safe_normalize(V + L2);
+ let NdotL2 = saturate(dot(N, L2));
+ let NdotH2 = saturate(dot(N, H2));
+ let VdotH2 = saturate(dot(V, H2));
+
+ let D2 = ggx_ndf(NdotH2, alpha);
+ let Gs2 = smith_g_schlick_ggx(NdotV, NdotL2, alpha);
+ let F2 = fresnel_schlick(VdotH2, F0);
+ let denom2 = max(4.0 * NdotV * NdotL2, EPS);
+ let spec2 = (D2 * Gs2) * F2 / denom2;
+ let kd2 = (vec3<f32>(1.0) - F2) * (1.0 - metallic);
+ let diff2 = kd2 * diffuse_lambert(diffuseColor);
+ let fill = (diff2 + spec2) * (globals.fill_light_color * NdotL2);
+
+ // IBL ambient lighting with energy conservation
+ let irradiance = textureSample(irradiance_map, ibl_sampler, N).rgb;
+
+ // Specular IBL
+ let specular_ambient = specular_ibl(N, V, F0, roughness);
+
+ // Energy conservation: kS is already accounted for in specular_ibl via F0
+ // kD ensures diffuse doesn't add energy where specular dominates
+ let kS = fresnel_schlick_roughness(NdotV, F0, roughness);
+ let kD = (1.0 - kS) * (1.0 - metallic);
+
+ let diffuse_ambient = kD * diffuseColor * irradiance;
+ let ambient = (diffuse_ambient + specular_ambient) * ao;
+
+ var col = direct + fill + ambient;
+
+ // simple tonemap + gamma
+ col = col / (col + vec3<f32>(1.0));
+ col = pow(col, vec3<f32>(INV_GAMMA));
+
+ return vec4<f32>(saturate3(col), 1.0);
+}
diff --git a/crates/renderbud/src/shadow.wgsl b/crates/renderbud/src/shadow.wgsl
@@ -0,0 +1,47 @@
+// Shadow depth pass: renders scene from light's perspective (depth only)
+
+struct Globals {
+ time: f32,
+ _pad0: f32,
+ resolution: vec2<f32>,
+
+ cam_pos: vec3<f32>,
+ _pad3: f32,
+
+ light_dir: vec3<f32>,
+ _pad1: f32,
+
+ light_color: vec3<f32>,
+ _pad2: f32,
+
+ fill_light_dir: vec3<f32>,
+ _pad4: f32,
+
+ fill_light_color: vec3<f32>,
+ _pad5: f32,
+
+ view_proj: mat4x4<f32>,
+ inv_view_proj: mat4x4<f32>,
+ light_view_proj: mat4x4<f32>,
+};
+
+struct Object {
+ model: mat4x4<f32>,
+ normal: mat4x4<f32>,
+};
+
+@group(0) @binding(0) var<uniform> globals: Globals;
+@group(1) @binding(0) var<uniform> object: Object;
+
+struct VSIn {
+ @location(0) pos: vec3<f32>,
+ @location(1) normal: vec3<f32>,
+ @location(2) uv: vec2<f32>,
+ @location(3) tangent: vec4<f32>,
+};
+
+@vertex
+fn vs_main(v: VSIn) -> @builtin(position) vec4<f32> {
+ let world4 = object.model * vec4<f32>(v.pos, 1.0);
+ return globals.light_view_proj * world4;
+}
diff --git a/crates/renderbud/src/skybox.wgsl b/crates/renderbud/src/skybox.wgsl
@@ -0,0 +1,61 @@
+struct Globals {
+ time: f32,
+ _pad0: f32,
+ resolution: vec2<f32>,
+
+ cam_pos: vec3<f32>,
+ _pad3: f32,
+
+ light_dir: vec3<f32>,
+ _pad1: f32,
+
+ light_color: vec3<f32>,
+ _pad2: f32,
+
+ fill_light_dir: vec3<f32>,
+ _pad4: f32,
+
+ fill_light_color: vec3<f32>,
+ _pad5: f32,
+
+ view_proj: mat4x4<f32>,
+ inv_view_proj: mat4x4<f32>,
+ light_view_proj: mat4x4<f32>,
+};
+
+@group(0) @binding(0) var<uniform> globals: Globals;
+@group(3) @binding(1) var ibl_sampler: sampler;
+@group(3) @binding(2) var prefiltered_map: texture_cube<f32>;
+
+struct VSOut {
+ @builtin(position) clip: vec4<f32>,
+ @location(0) ray_dir: vec3<f32>,
+};
+
+@vertex
+fn vs_main(@builtin(vertex_index) vi: u32) -> VSOut {
+ var out: VSOut;
+
+ // Fullscreen triangle: vertices at (-1,-1), (3,-1), (-1,3)
+ let x = f32((vi << 1u) & 2u) * 2.0 - 1.0;
+ let y = f32(vi & 2u) * 2.0 - 1.0;
+ out.clip = vec4<f32>(x, y, 1.0, 1.0);
+
+ // Unproject to get ray direction
+ let near = globals.inv_view_proj * vec4<f32>(x, y, 0.0, 1.0);
+ let far = globals.inv_view_proj * vec4<f32>(x, y, 1.0, 1.0);
+ out.ray_dir = normalize(far.xyz / far.w - near.xyz / near.w);
+
+ return out;
+}
+
+@fragment
+fn fs_main(in: VSOut) -> @location(0) vec4<f32> {
+ // Sample prefiltered map at slight blur level (mip 1 of 5)
+ let hdr = textureSampleLevel(prefiltered_map, ibl_sampler, in.ray_dir, 1.0).rgb;
+
+ // Reinhard tonemap
+ let col = hdr / (hdr + vec3<f32>(1.0));
+
+ return vec4<f32>(clamp(col, vec3<f32>(0.0), vec3<f32>(1.0)), 1.0);
+}
diff --git a/crates/renderbud/src/texture.rs b/crates/renderbud/src/texture.rs
@@ -0,0 +1,121 @@
+pub fn make_1x1_rgba8(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ format: wgpu::TextureFormat,
+ rgba: [u8; 4],
+ label: &str,
+) -> wgpu::TextureView {
+ let extent = wgpu::Extent3d {
+ width: 1,
+ height: 1,
+ depth_or_array_layers: 1,
+ };
+ let tex = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &tex,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ &rgba,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(4),
+ rows_per_image: Some(1),
+ },
+ extent,
+ );
+
+ tex.create_view(&wgpu::TextureViewDescriptor::default())
+}
+
+pub fn texture_layout_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
+ wgpu::BindGroupLayoutEntry {
+ binding,
+ visibility: wgpu::ShaderStages::FRAGMENT,
+ ty: wgpu::BindingType::Texture {
+ multisampled: false,
+ view_dimension: wgpu::TextureViewDimension::D2,
+ sample_type: wgpu::TextureSampleType::Float { filterable: true },
+ },
+ count: None,
+ }
+}
+
+/// Robust texture upload helper (handles row padding)
+/// "queue.write_texture is annoying once width*4 isn't 256-aligned. This helper always works"
+pub fn upload_rgba8_texture_2d(
+ device: &wgpu::Device,
+ queue: &wgpu::Queue,
+ width: u32,
+ height: u32,
+ rgba: &[u8],
+ format: wgpu::TextureFormat,
+ label: &str,
+) -> wgpu::TextureView {
+ assert_eq!(rgba.len(), (width * height * 4) as usize);
+ assert!(!rgba.is_empty());
+
+ let extent = wgpu::Extent3d {
+ width,
+ height,
+ depth_or_array_layers: 1,
+ };
+ let texture = device.create_texture(&wgpu::TextureDescriptor {
+ label: Some(label),
+ size: extent,
+ mip_level_count: 1,
+ sample_count: 1,
+ dimension: wgpu::TextureDimension::D2,
+ format,
+ usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
+ view_formats: &[],
+ });
+
+ let bytes_per_pixel = 4usize;
+ let unpadded_bytes_per_row = width as usize * bytes_per_pixel;
+ let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT as usize; // 256
+
+ // CEIL division to next multiple of 256
+ let padded_bytes_per_row = unpadded_bytes_per_row.div_ceil(align) * align;
+
+ assert!(padded_bytes_per_row >= unpadded_bytes_per_row);
+ assert!(padded_bytes_per_row.is_multiple_of(align));
+
+ let mut padded = vec![0u8; padded_bytes_per_row * height as usize];
+ for y in 0..height as usize {
+ let src = &rgba[y * unpadded_bytes_per_row..(y + 1) * unpadded_bytes_per_row];
+ let dst = &mut padded
+ [y * padded_bytes_per_row..y * padded_bytes_per_row + unpadded_bytes_per_row];
+ dst.copy_from_slice(src);
+ }
+
+ queue.write_texture(
+ wgpu::TexelCopyTextureInfo {
+ texture: &texture,
+ mip_level: 0,
+ origin: wgpu::Origin3d::ZERO,
+ aspect: wgpu::TextureAspect::All,
+ },
+ &padded,
+ wgpu::TexelCopyBufferLayout {
+ offset: 0,
+ bytes_per_row: Some(padded_bytes_per_row as u32),
+ rows_per_image: Some(height),
+ },
+ extent,
+ );
+
+ texture.create_view(&wgpu::TextureViewDescriptor::default())
+}
diff --git a/crates/renderbud/src/world.rs b/crates/renderbud/src/world.rs
@@ -0,0 +1,908 @@
+use glam::{Mat4, Quat, Vec3};
+
+use crate::camera::Camera;
+use crate::model::Model;
+
+/// A unique handle for a node in the scene graph.
+/// Uses arena index + generation to prevent stale handle reuse.
+#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
+pub struct NodeId {
+ pub index: u32,
+ pub generation: u32,
+}
+
+/// Backward-compatible alias for existing code that uses ObjectId.
+pub type ObjectId = NodeId;
+
+/// Transform for a scene node (position, rotation, scale).
+#[derive(Clone, Debug)]
+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 fn from_translation(t: Vec3) -> Self {
+ Self {
+ translation: t,
+ ..Default::default()
+ }
+ }
+
+ pub fn to_matrix(&self) -> Mat4 {
+ Mat4::from_scale_rotation_translation(self.scale, self.rotation, self.translation)
+ }
+}
+
+/// A node in the scene graph.
+pub struct Node {
+ /// Local transform relative to parent (or world if root).
+ pub local: Transform,
+
+ /// Cached world-space matrix. Valid when `dirty == false`.
+ world_matrix: Mat4,
+
+ /// When true, world_matrix needs recomputation.
+ dirty: bool,
+
+ /// Generation for this slot (matches NodeId.generation when alive).
+ generation: u32,
+
+ /// Parent node. None means this is a root node.
+ parent: Option<NodeId>,
+
+ /// First child (intrusive linked list through siblings).
+ first_child: Option<NodeId>,
+
+ /// Next sibling in parent's child list.
+ next_sibling: Option<NodeId>,
+
+ /// If Some, this node is renderable with the given Model handle.
+ /// If None, this is a grouping/transform-only node.
+ pub model: Option<Model>,
+
+ /// Whether this slot is occupied.
+ alive: bool,
+}
+
+impl Node {
+ /// Get the cached world-space matrix.
+ /// Only valid after `update_world_transforms()`.
+ pub fn world_matrix(&self) -> Mat4 {
+ self.world_matrix
+ }
+}
+
+pub struct World {
+ pub camera: Camera,
+
+ /// Arena of all nodes.
+ nodes: Vec<Node>,
+
+ /// Free slot indices for reuse.
+ free_list: Vec<u32>,
+
+ /// Cached list of NodeIds that have a Model (renderable).
+ /// Rebuilt when renderables_dirty is true.
+ renderables: Vec<NodeId>,
+
+ /// True when renderables list needs rebuilding.
+ renderables_dirty: bool,
+
+ pub selected_object: Option<NodeId>,
+}
+
+impl World {
+ pub fn new(camera: Camera) -> Self {
+ Self {
+ camera,
+ nodes: Vec::new(),
+ free_list: Vec::new(),
+ renderables: Vec::new(),
+ renderables_dirty: false,
+ selected_object: None,
+ }
+ }
+
+ // ── Arena internals ──────────────────────────────────────────
+
+ fn alloc_slot(&mut self) -> (u32, u32) {
+ if let Some(index) = self.free_list.pop() {
+ let node = &mut self.nodes[index as usize];
+ node.generation += 1;
+ node.alive = true;
+ node.dirty = true;
+ node.parent = None;
+ node.first_child = None;
+ node.next_sibling = None;
+ node.model = None;
+ node.world_matrix = Mat4::IDENTITY;
+ (index, node.generation)
+ } else {
+ let index = self.nodes.len() as u32;
+ self.nodes.push(Node {
+ local: Transform::default(),
+ world_matrix: Mat4::IDENTITY,
+ dirty: true,
+ generation: 0,
+ parent: None,
+ first_child: None,
+ next_sibling: None,
+ model: None,
+ alive: true,
+ });
+ (index, 0)
+ }
+ }
+
+ fn is_valid(&self, id: NodeId) -> bool {
+ let idx = id.index as usize;
+ idx < self.nodes.len()
+ && self.nodes[idx].alive
+ && self.nodes[idx].generation == id.generation
+ }
+
+ fn mark_dirty(&mut self, id: NodeId) {
+ let mut stack = vec![id];
+ while let Some(nid) = stack.pop() {
+ let node = &mut self.nodes[nid.index as usize];
+ if node.dirty {
+ continue;
+ }
+ node.dirty = true;
+ let mut child = node.first_child;
+ while let Some(c) = child {
+ stack.push(c);
+ child = self.nodes[c.index as usize].next_sibling;
+ }
+ }
+ }
+
+ fn attach_child(&mut self, parent: NodeId, child: NodeId) {
+ let old_first = self.nodes[parent.index as usize].first_child;
+ self.nodes[child.index as usize].next_sibling = old_first;
+ self.nodes[parent.index as usize].first_child = Some(child);
+ }
+
+ fn detach_child(&mut self, parent: NodeId, child: NodeId) {
+ let first = self.nodes[parent.index as usize].first_child;
+ if first == Some(child) {
+ self.nodes[parent.index as usize].first_child =
+ self.nodes[child.index as usize].next_sibling;
+ } else {
+ let mut prev = first;
+ while let Some(p) = prev {
+ let next = self.nodes[p.index as usize].next_sibling;
+ if next == Some(child) {
+ self.nodes[p.index as usize].next_sibling =
+ self.nodes[child.index as usize].next_sibling;
+ break;
+ }
+ prev = next;
+ }
+ }
+ self.nodes[child.index as usize].next_sibling = None;
+ }
+
+ fn is_ancestor(&self, ancestor: NodeId, node: NodeId) -> bool {
+ let mut cur = Some(node);
+ while let Some(c) = cur {
+ if c == ancestor {
+ return true;
+ }
+ cur = self.nodes[c.index as usize].parent;
+ }
+ false
+ }
+
+ // ── Public scene graph API ───────────────────────────────────
+
+ /// Create a grouping node (no model) with an optional parent.
+ pub fn create_node(&mut self, local: Transform, parent: Option<NodeId>) -> NodeId {
+ let (index, generation) = self.alloc_slot();
+ self.nodes[index as usize].local = local;
+
+ let id = NodeId { index, generation };
+
+ if let Some(p) = parent {
+ if self.is_valid(p) {
+ self.nodes[index as usize].parent = Some(p);
+ self.attach_child(p, id);
+ }
+ }
+
+ id
+ }
+
+ /// Create a renderable node with a Model and optional parent.
+ pub fn create_renderable(
+ &mut self,
+ model: Model,
+ local: Transform,
+ parent: Option<NodeId>,
+ ) -> NodeId {
+ let id = self.create_node(local, parent);
+ self.nodes[id.index as usize].model = Some(model);
+ self.renderables_dirty = true;
+ id
+ }
+
+ /// Remove a node and all its descendants.
+ pub fn remove_node(&mut self, id: NodeId) -> bool {
+ if !self.is_valid(id) {
+ return false;
+ }
+
+ // Collect all nodes in the subtree
+ let mut to_remove = Vec::new();
+ let mut stack = vec![id];
+ while let Some(nid) = stack.pop() {
+ to_remove.push(nid);
+ let mut child = self.nodes[nid.index as usize].first_child;
+ while let Some(c) = child {
+ stack.push(c);
+ child = self.nodes[c.index as usize].next_sibling;
+ }
+ }
+
+ // Detach root of subtree from its parent
+ if let Some(parent_id) = self.nodes[id.index as usize].parent {
+ self.detach_child(parent_id, id);
+ }
+
+ // Free all collected nodes
+ for nid in &to_remove {
+ let node = &mut self.nodes[nid.index as usize];
+ node.alive = false;
+ node.first_child = None;
+ node.next_sibling = None;
+ node.parent = None;
+ node.model = None;
+ self.free_list.push(nid.index);
+ }
+
+ self.renderables_dirty = true;
+ true
+ }
+
+ /// Set a node's local transform. Marks it and descendants dirty.
+ pub fn set_local_transform(&mut self, id: NodeId, local: Transform) -> bool {
+ if !self.is_valid(id) {
+ return false;
+ }
+ self.nodes[id.index as usize].local = local;
+ self.mark_dirty(id);
+ true
+ }
+
+ /// Reparent a node. Pass None to make it a root node.
+ pub fn set_parent(&mut self, id: NodeId, new_parent: Option<NodeId>) -> bool {
+ if !self.is_valid(id) {
+ return false;
+ }
+ if let Some(p) = new_parent {
+ if !self.is_valid(p) {
+ return false;
+ }
+ if self.is_ancestor(id, p) {
+ return false;
+ }
+ }
+
+ // Detach from old parent
+ if let Some(old_parent) = self.nodes[id.index as usize].parent {
+ self.detach_child(old_parent, id);
+ }
+
+ // Attach to new parent
+ self.nodes[id.index as usize].parent = new_parent;
+ if let Some(p) = new_parent {
+ self.attach_child(p, id);
+ }
+
+ self.mark_dirty(id);
+ true
+ }
+
+ /// Attach or detach a Model on an existing node.
+ pub fn set_model(&mut self, id: NodeId, model: Option<Model>) -> bool {
+ if !self.is_valid(id) {
+ return false;
+ }
+ self.nodes[id.index as usize].model = model;
+ self.renderables_dirty = true;
+ true
+ }
+
+ /// Get the cached world matrix for a node.
+ pub fn world_matrix(&self, id: NodeId) -> Option<Mat4> {
+ if !self.is_valid(id) {
+ return None;
+ }
+ Some(self.nodes[id.index as usize].world_matrix)
+ }
+
+ /// Get a node's local transform.
+ pub fn local_transform(&self, id: NodeId) -> Option<&Transform> {
+ if !self.is_valid(id) {
+ return None;
+ }
+ Some(&self.nodes[id.index as usize].local)
+ }
+
+ /// Get a node by id.
+ pub fn get_node(&self, id: NodeId) -> Option<&Node> {
+ if !self.is_valid(id) {
+ return None;
+ }
+ Some(&self.nodes[id.index as usize])
+ }
+
+ /// Iterate renderable node ids (nodes with a Model).
+ pub fn renderables(&self) -> &[NodeId] {
+ &self.renderables
+ }
+
+ /// Recompute world matrices for all dirty nodes. Call once per frame.
+ pub fn update_world_transforms(&mut self) {
+ // Rebuild renderables list if needed
+ if self.renderables_dirty {
+ self.renderables.clear();
+ for (i, node) in self.nodes.iter().enumerate() {
+ if node.alive && node.model.is_some() {
+ self.renderables.push(NodeId {
+ index: i as u32,
+ generation: node.generation,
+ });
+ }
+ }
+ self.renderables_dirty = false;
+ }
+
+ // Process root nodes (no parent) and recurse into children
+ for i in 0..self.nodes.len() {
+ let node = &self.nodes[i];
+ if !node.alive || !node.dirty || node.parent.is_some() {
+ continue;
+ }
+ self.nodes[i].world_matrix = self.nodes[i].local.to_matrix();
+ self.nodes[i].dirty = false;
+ self.update_children(i);
+ }
+
+ // Second pass: catch any remaining dirty nodes (reparented mid-frame)
+ for i in 0..self.nodes.len() {
+ if self.nodes[i].alive && self.nodes[i].dirty {
+ self.recompute_world_matrix(i);
+ }
+ }
+ }
+
+ fn update_children(&mut self, parent_idx: usize) {
+ let parent_world = self.nodes[parent_idx].world_matrix;
+ let mut child_id = self.nodes[parent_idx].first_child;
+ while let Some(cid) = child_id {
+ let ci = cid.index as usize;
+ if self.nodes[ci].alive {
+ let local = self.nodes[ci].local.to_matrix();
+ self.nodes[ci].world_matrix = parent_world * local;
+ self.nodes[ci].dirty = false;
+ self.update_children(ci);
+ }
+ child_id = self.nodes[ci].next_sibling;
+ }
+ }
+
+ fn recompute_world_matrix(&mut self, index: usize) {
+ // Build chain from this node up to root
+ let mut chain = Vec::with_capacity(8);
+ let mut cur = index;
+ loop {
+ chain.push(cur);
+ match self.nodes[cur].parent {
+ Some(p) if self.nodes[p.index as usize].alive => {
+ cur = p.index as usize;
+ }
+ _ => break,
+ }
+ }
+
+ // Walk from root down to target
+ chain.reverse();
+ let mut parent_world = Mat4::IDENTITY;
+ for &idx in &chain {
+ let node = &self.nodes[idx];
+ if !node.dirty {
+ parent_world = node.world_matrix;
+ continue;
+ }
+ let world = parent_world * node.local.to_matrix();
+ self.nodes[idx].world_matrix = world;
+ self.nodes[idx].dirty = false;
+ parent_world = world;
+ }
+ }
+
+ // ── Backward-compatible API ──────────────────────────────────
+
+ /// Legacy: place a renderable object as a root node.
+ pub fn add_object(&mut self, model: Model, transform: Transform) -> ObjectId {
+ self.create_renderable(model, transform, None)
+ }
+
+ /// Legacy: remove an object.
+ pub fn remove_object(&mut self, id: ObjectId) -> bool {
+ self.remove_node(id)
+ }
+
+ /// Legacy: update an object's transform.
+ pub fn update_transform(&mut self, id: ObjectId, transform: Transform) -> bool {
+ self.set_local_transform(id, transform)
+ }
+
+ /// Legacy: get a node by object id.
+ pub fn get_object(&self, id: ObjectId) -> Option<&Node> {
+ self.get_node(id)
+ }
+
+ /// Number of renderable objects in the scene.
+ pub fn num_objects(&self) -> usize {
+ self.renderables.len()
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::model::Model;
+ use glam::Vec3;
+
+ fn test_world() -> World {
+ World::new(Camera::new(Vec3::new(0.0, 2.0, 5.0), Vec3::ZERO))
+ }
+
+ fn model(id: u64) -> Model {
+ Model { id }
+ }
+
+ // ── Arena basics ──────────────────────────────────────────────
+
+ #[test]
+ fn create_node_returns_valid_id() {
+ let mut w = test_world();
+ let id = w.create_node(Transform::default(), None);
+ assert!(w.is_valid(id));
+ assert!(w.get_node(id).is_some());
+ }
+
+ #[test]
+ fn create_renderable_appears_in_renderables() {
+ let mut w = test_world();
+ let id = w.create_renderable(model(1), Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.renderables().len(), 1);
+ assert_eq!(w.renderables()[0], id);
+ }
+
+ #[test]
+ fn grouping_node_not_in_renderables() {
+ let mut w = test_world();
+ w.create_node(Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.renderables().len(), 0);
+ }
+
+ #[test]
+ fn multiple_renderables() {
+ let mut w = test_world();
+ let a = w.create_renderable(model(1), Transform::default(), None);
+ let b = w.create_renderable(model(2), Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 2);
+ let ids = w.renderables();
+ assert!(ids.contains(&a));
+ assert!(ids.contains(&b));
+ }
+
+ // ── Removal and free list ─────────────────────────────────────
+
+ #[test]
+ fn remove_node_invalidates_id() {
+ let mut w = test_world();
+ let id = w.create_renderable(model(1), Transform::default(), None);
+ assert!(w.remove_node(id));
+ assert!(!w.is_valid(id));
+ assert!(w.get_node(id).is_none());
+ }
+
+ #[test]
+ fn remove_node_clears_renderables() {
+ let mut w = test_world();
+ let id = w.create_renderable(model(1), Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 1);
+ w.remove_node(id);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 0);
+ }
+
+ #[test]
+ fn stale_handle_after_reuse() {
+ let mut w = test_world();
+ let old = w.create_node(Transform::default(), None);
+ w.remove_node(old);
+ // Allocate a new node, which should reuse the slot with bumped generation
+ let new = w.create_node(Transform::default(), None);
+ assert_eq!(old.index, new.index);
+ assert_ne!(old.generation, new.generation);
+ // Old handle must be invalid
+ assert!(!w.is_valid(old));
+ assert!(w.is_valid(new));
+ }
+
+ #[test]
+ fn remove_nonexistent_returns_false() {
+ let mut w = test_world();
+ let fake = NodeId {
+ index: 99,
+ generation: 0,
+ };
+ assert!(!w.remove_node(fake));
+ }
+
+ // ── Parent-child relationships ────────────────────────────────
+
+ #[test]
+ fn create_with_parent() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::default(), None);
+ let child = w.create_node(Transform::default(), Some(parent));
+ let parent_node = w.get_node(parent).unwrap();
+ assert_eq!(parent_node.first_child, Some(child));
+ }
+
+ #[test]
+ fn reparent_node() {
+ let mut w = test_world();
+ let a = w.create_node(Transform::default(), None);
+ let b = w.create_node(Transform::default(), None);
+ let child = w.create_node(Transform::default(), Some(a));
+
+ // Child is under a
+ assert_eq!(w.get_node(a).unwrap().first_child, Some(child));
+
+ // Reparent to b
+ assert!(w.set_parent(child, Some(b)));
+ assert!(w.get_node(a).unwrap().first_child.is_none());
+ assert_eq!(w.get_node(b).unwrap().first_child, Some(child));
+ }
+
+ #[test]
+ fn reparent_to_none_makes_root() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::default(), None);
+ let child = w.create_node(Transform::default(), Some(parent));
+ assert!(w.set_parent(child, None));
+ assert!(w.get_node(parent).unwrap().first_child.is_none());
+ }
+
+ #[test]
+ fn cycle_prevention() {
+ let mut w = test_world();
+ let a = w.create_node(Transform::default(), None);
+ let b = w.create_node(Transform::default(), Some(a));
+ let c = w.create_node(Transform::default(), Some(b));
+
+ // Trying to make a a child of c should fail (c -> b -> a cycle)
+ assert!(!w.set_parent(a, Some(c)));
+
+ // Trying to make a a child of b should also fail
+ assert!(!w.set_parent(a, Some(b)));
+
+ // Self-parenting should fail
+ assert!(!w.set_parent(a, Some(a)));
+ }
+
+ #[test]
+ fn remove_subtree() {
+ let mut w = test_world();
+ let root = w.create_node(Transform::default(), None);
+ let child = w.create_renderable(model(1), Transform::default(), Some(root));
+ let grandchild = w.create_renderable(model(2), Transform::default(), Some(child));
+
+ w.remove_node(root);
+
+ assert!(!w.is_valid(root));
+ assert!(!w.is_valid(child));
+ assert!(!w.is_valid(grandchild));
+ }
+
+ #[test]
+ fn remove_child_detaches_from_parent() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::default(), None);
+ let c1 = w.create_node(Transform::default(), Some(parent));
+ let c2 = w.create_node(Transform::default(), Some(parent));
+
+ w.remove_node(c1);
+
+ // Parent should still have c2
+ assert!(w.is_valid(parent));
+ assert!(w.is_valid(c2));
+ let parent_node = w.get_node(parent).unwrap();
+ assert_eq!(parent_node.first_child, Some(c2));
+ }
+
+ // ── Transform computation ─────────────────────────────────────
+
+ #[test]
+ fn root_world_matrix_equals_local() {
+ let mut w = test_world();
+ let t = Transform::from_translation(Vec3::new(1.0, 2.0, 3.0));
+ let expected = t.to_matrix();
+ let id = w.create_node(t, None);
+ w.update_world_transforms();
+ assert_eq!(w.world_matrix(id).unwrap(), expected);
+ }
+
+ #[test]
+ fn child_inherits_parent_transform() {
+ let mut w = test_world();
+ let parent_t = Transform::from_translation(Vec3::new(10.0, 0.0, 0.0));
+ let child_t = Transform::from_translation(Vec3::new(0.0, 5.0, 0.0));
+
+ let parent = w.create_node(parent_t.clone(), None);
+ let child = w.create_node(child_t.clone(), Some(parent));
+ w.update_world_transforms();
+
+ let expected = parent_t.to_matrix() * child_t.to_matrix();
+ let actual = w.world_matrix(child).unwrap();
+
+ // Check that the child's world position is (10, 5, 0)
+ let pos = actual.col(3);
+ assert!((pos.x - 10.0).abs() < 1e-5);
+ assert!((pos.y - 5.0).abs() < 1e-5);
+ assert!((pos.z - 0.0).abs() < 1e-5);
+ assert_eq!(actual, expected);
+ }
+
+ #[test]
+ fn grandchild_transform_chain() {
+ let mut w = test_world();
+ let t1 = Transform::from_translation(Vec3::new(1.0, 0.0, 0.0));
+ let t2 = Transform::from_translation(Vec3::new(0.0, 2.0, 0.0));
+ let t3 = Transform::from_translation(Vec3::new(0.0, 0.0, 3.0));
+
+ let a = w.create_node(t1.clone(), None);
+ let b = w.create_node(t2.clone(), Some(a));
+ let c = w.create_node(t3.clone(), Some(b));
+ w.update_world_transforms();
+
+ let world_c = w.world_matrix(c).unwrap();
+ let pos = world_c.col(3);
+ assert!((pos.x - 1.0).abs() < 1e-5);
+ assert!((pos.y - 2.0).abs() < 1e-5);
+ assert!((pos.z - 3.0).abs() < 1e-5);
+ }
+
+ // ── Dirty flag propagation ────────────────────────────────────
+
+ #[test]
+ fn moving_parent_updates_children() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::from_translation(Vec3::X), None);
+ let child = w.create_node(Transform::from_translation(Vec3::Y), Some(parent));
+ w.update_world_transforms();
+
+ // Verify initial position
+ let pos = w.world_matrix(child).unwrap().col(3);
+ assert!((pos.x - 1.0).abs() < 1e-5);
+ assert!((pos.y - 1.0).abs() < 1e-5);
+
+ // Move parent
+ w.set_local_transform(
+ parent,
+ Transform::from_translation(Vec3::new(5.0, 0.0, 0.0)),
+ );
+ w.update_world_transforms();
+
+ // Child should now be at (5, 1, 0)
+ let pos = w.world_matrix(child).unwrap().col(3);
+ assert!((pos.x - 5.0).abs() < 1e-5);
+ assert!((pos.y - 1.0).abs() < 1e-5);
+ }
+
+ #[test]
+ fn set_local_transform_invalid_id() {
+ let mut w = test_world();
+ let fake = NodeId {
+ index: 0,
+ generation: 99,
+ };
+ assert!(!w.set_local_transform(fake, Transform::default()));
+ }
+
+ // ── set_model ─────────────────────────────────────────────────
+
+ #[test]
+ fn attach_model_to_grouping_node() {
+ let mut w = test_world();
+ let id = w.create_node(Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 0);
+
+ w.set_model(id, Some(model(42)));
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 1);
+ }
+
+ #[test]
+ fn detach_model_from_renderable() {
+ let mut w = test_world();
+ let id = w.create_renderable(model(1), Transform::default(), None);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 1);
+
+ w.set_model(id, None);
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 0);
+ // Node still valid, just no longer renderable
+ assert!(w.is_valid(id));
+ }
+
+ // ── Backward-compatible API ───────────────────────────────────
+
+ #[test]
+ fn legacy_add_remove_object() {
+ let mut w = test_world();
+ let id = w.add_object(model(1), Transform::from_translation(Vec3::Z));
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 1);
+ assert!(w.get_object(id).is_some());
+
+ assert!(w.remove_object(id));
+ w.update_world_transforms();
+ assert_eq!(w.num_objects(), 0);
+ }
+
+ #[test]
+ fn legacy_update_transform() {
+ let mut w = test_world();
+ let id = w.add_object(model(1), Transform::from_translation(Vec3::ZERO));
+ w.update_world_transforms();
+
+ let new_t = Transform::from_translation(Vec3::new(7.0, 8.0, 9.0));
+ assert!(w.update_transform(id, new_t));
+ w.update_world_transforms();
+
+ let pos = w.world_matrix(id).unwrap().col(3);
+ assert!((pos.x - 7.0).abs() < 1e-5);
+ assert!((pos.y - 8.0).abs() < 1e-5);
+ assert!((pos.z - 9.0).abs() < 1e-5);
+ }
+
+ // ── Multiple siblings ─────────────────────────────────────────
+
+ #[test]
+ fn multiple_children_all_transform_correctly() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)), None);
+ let c1 = w.create_node(
+ Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)),
+ Some(parent),
+ );
+ let c2 = w.create_node(
+ Transform::from_translation(Vec3::new(2.0, 0.0, 0.0)),
+ Some(parent),
+ );
+ let c3 = w.create_node(
+ Transform::from_translation(Vec3::new(3.0, 0.0, 0.0)),
+ Some(parent),
+ );
+ w.update_world_transforms();
+
+ assert!((w.world_matrix(c1).unwrap().col(3).x - 11.0).abs() < 1e-5);
+ assert!((w.world_matrix(c2).unwrap().col(3).x - 12.0).abs() < 1e-5);
+ assert!((w.world_matrix(c3).unwrap().col(3).x - 13.0).abs() < 1e-5);
+ }
+
+ #[test]
+ fn remove_middle_sibling() {
+ let mut w = test_world();
+ let parent = w.create_node(Transform::default(), None);
+ let c1 = w.create_node(Transform::default(), Some(parent));
+ let c2 = w.create_node(Transform::default(), Some(parent));
+ let c3 = w.create_node(Transform::default(), Some(parent));
+
+ w.remove_node(c2);
+
+ assert!(w.is_valid(c1));
+ assert!(!w.is_valid(c2));
+ assert!(w.is_valid(c3));
+
+ // Parent should still link to c1 and c3
+ // (linked list: c3 -> c1 after c2 removed, since prepend order is c3, c2, c1)
+ let mut count = 0;
+ let mut cur = w.get_node(parent).unwrap().first_child;
+ while let Some(c) = cur {
+ count += 1;
+ cur = w.get_node(c).unwrap().next_sibling;
+ }
+ assert_eq!(count, 2);
+ }
+
+ // ── Scale and rotation ────────────────────────────────────────
+
+ #[test]
+ fn scaled_parent_affects_child_position() {
+ let mut w = test_world();
+ let parent_t = Transform {
+ translation: Vec3::ZERO,
+ rotation: Quat::IDENTITY,
+ scale: Vec3::splat(2.0),
+ };
+ let child_t = Transform::from_translation(Vec3::new(1.0, 0.0, 0.0));
+
+ let parent = w.create_node(parent_t, None);
+ let child = w.create_node(child_t, Some(parent));
+ w.update_world_transforms();
+
+ // Child at local (1,0,0) under 2x scale parent should be at world (2,0,0)
+ let pos = w.world_matrix(child).unwrap().col(3);
+ assert!((pos.x - 2.0).abs() < 1e-5);
+ }
+
+ // ── Reparent updates transforms ───────────────────────────────
+
+ #[test]
+ fn reparent_recomputes_world_matrix() {
+ let mut w = test_world();
+ let a = w.create_node(Transform::from_translation(Vec3::new(10.0, 0.0, 0.0)), None);
+ let b = w.create_node(Transform::from_translation(Vec3::new(20.0, 0.0, 0.0)), None);
+ let child = w.create_node(
+ Transform::from_translation(Vec3::new(1.0, 0.0, 0.0)),
+ Some(a),
+ );
+ w.update_world_transforms();
+
+ // Under a: world x = 11
+ assert!((w.world_matrix(child).unwrap().col(3).x - 11.0).abs() < 1e-5);
+
+ // Reparent to b
+ w.set_parent(child, Some(b));
+ w.update_world_transforms();
+
+ // Under b: world x = 21
+ assert!((w.world_matrix(child).unwrap().col(3).x - 21.0).abs() < 1e-5);
+ }
+
+ // ── Edge case: empty world ────────────────────────────────────
+
+ #[test]
+ fn empty_world_update_is_safe() {
+ let mut w = test_world();
+ w.update_world_transforms();
+ assert_eq!(w.renderables().len(), 0);
+ }
+
+ #[test]
+ fn world_matrix_invalid_id_returns_none() {
+ let w = test_world();
+ let fake = NodeId {
+ index: 0,
+ generation: 0,
+ };
+ assert!(w.world_matrix(fake).is_none());
+ }
+}