notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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:
MCargo.lock | 1+
MCargo.toml | 3++-
Mcrates/notedeck_dave/Cargo.toml | 2+-
Acrates/renderbud/.rustfmt.toml | 1+
Acrates/renderbud/COPYING | 1+
Acrates/renderbud/Cargo.lock | 2743+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/Cargo.toml | 25+++++++++++++++++++++++++
Acrates/renderbud/Makefile | 5+++++
Acrates/renderbud/README.md | 15+++++++++++++++
Acrates/renderbud/assets/kloofendal_43d_clear_1k.hdr | 0
Acrates/renderbud/assets/venice_sunset_1k.hdr | 0
Acrates/renderbud/examples/ironwood.rs | 259+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/camera.rs | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/egui.rs | 73+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/grid.wgsl | 144+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/ibl.rs | 947+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/lib.rs | 980+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/material.rs | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/model.rs | 617+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/shader.wgsl | 299+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/shadow.wgsl | 47+++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/skybox.wgsl | 61+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/texture.rs | 121+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/world.rs | 908+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
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()); + } +}