diff --git a/Cargo.lock b/Cargo.lock index 76f88df..d9bf8e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,36 +2,361 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "arrayvec" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" +dependencies = [ + "serde", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bit-set" +version = "0.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 = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "cfg-if" version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "codespan-reporting" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe6d2e5af09e8c8ad56c969f2157a3d4238cebc7c55f0a517728c38f7b200f81" +dependencies = [ + "serde", + "termcolor", + "unicode-width", +] + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", + "serde", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + [[package]] name = "itoa" version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + [[package]] name = "memchr" version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "naga" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916cbc7cb27db60be930a4e2da243cf4bc39569195f22fd8ee419cd31d5b662c" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags", + "cfg-if", + "cfg_aliases", + "codespan-reporting", + "half", + "hashbrown 0.15.5", + "hexf-parse", + "indexmap", + "libm", + "log", + "num-traits", + "once_cell", + "rustc-hash", + "thiserror", + "unicode-ident", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", + "libm", +] + [[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[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", + "smallvec", + "windows-link", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "proc-macro2" version = "1.0.107" @@ -41,6 +366,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "profiling" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" + [[package]] name = "quote" version = "1.0.47" @@ -50,12 +381,45 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[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 = "rustversion" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "serde" version = "1.0.229" @@ -63,6 +427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -98,6 +463,24 @@ dependencies = [ "zmij", ] +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "syn" version = "2.0.119" @@ -120,12 +503,47 @@ dependencies = [ "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 = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "unicode-ident" version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "wasm-bindgen" version = "0.2.127" @@ -139,6 +557,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.127" @@ -171,12 +599,173 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70b6ff82bbf6e9206828e1a3178e851f8c20f1c9028e74dd3a8090741ccd5798" +dependencies = [ + "arrayvec", + "bitflags", + "cfg-if", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "js-sys", + "log", + "naga", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "26.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5f62f1053bd28c2268f42916f31588f81f64796e2ff91b81293515017ca8bd9" +dependencies = [ + "arrayvec", + "bit-set", + "bit-vec", + "bitflags", + "cfg_aliases", + "document-features", + "hashbrown 0.15.5", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash", + "serde", + "smallvec", + "thiserror", + "wgpu-core-deps-windows-linux-android", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "720a5cb9d12b3d337c15ff0e24d3e97ed11490ff3f7506e7f3d98c68fa5d6f14" +dependencies = [ + "wgpu-hal", +] + +[[package]] +name = "wgpu-hal" +version = "26.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8d0e67224cc7305b3b4eb2cc57ca4c4c3afc665c1d1bee162ea806e19c47bdd" +dependencies = [ + "bitflags", + "cfg-if", + "cfg_aliases", + "libloading", + "log", + "naga", + "portable-atomic", + "portable-atomic-util", + "raw-window-handle", + "renderdoc-sys", + "thiserror", + "wgpu-types", +] + +[[package]] +name = "wgpu-types" +version = "26.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eca7a8d8af57c18f57d393601a1fb159ace8b2328f1b6b5f80893f7d672c9ae2" +dependencies = [ + "bitflags", + "bytemuck", + "js-sys", + "log", + "serde", + "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", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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 = "yawn-core" version = "0.1.0" dependencies = [ + "gloo-timers", + "js-sys", + "serde", "serde_json", "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] diff --git a/README.md b/README.md index 90f6432..fe3462e 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,14 @@ # Yawn -Yawn Core is two things: a generic structure-of-arrays arena in a `SharedArrayBuffer`, and a render-graph worker that turns externally supplied WGSL into an up-front WebGPU loadout. +Yawn Core is two things: a generic structure-of-arrays arena in a `SharedArrayBuffer`, and a Rust render graph that turns externally supplied WGSL into an up-front WebGPU loadout. ```text -JSO or FXNode → AST → S-expression → graph worker → WebGPU - ↑ -any thread → direct shared row writes ┘ +JSO or FXNode → AST → S-expression → worker messages → Rust/WebGPU + ↑ +any thread → direct shared row writes ─────────────┘ ``` -Messages allocate `{ name, rows, stride, format }` arrays and load graphs. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations. +The arena starts with only one eight-float `info` row for frame timing and direct SAB render skipping. Messages create or delete other `{ name, rows, stride, format }` arrays, allocate named slots, compile graphs, switch loadouts, and control render pacing. Existing render data is changed by writing `f32`, `u32`, or `i32` rows directly. Allocations are 64-byte aligned, row strides are multiples of 16 bytes, and compatible non-overlapping transient textures share physical allocations. WGSL, pipelines, glTF import, and conventional mesh/camera/material handles live in `addons/`; core contains no shader or scene model. diff --git a/core/Cargo.toml b/core/Cargo.toml index 048e9eb..da0c9c4 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -6,7 +6,14 @@ edition = "2021" [lib] crate-type = ["cdylib"] +path = "lib.rs" [dependencies] +gloo-timers = { version = "0.3", features = ["futures"] } +js-sys = "0.3" +serde = { version = "1", features = ["derive"] } serde_json = "1" wasm-bindgen = "0.2" +wasm-bindgen-futures = "0.4" +web-sys = { version = "0.3", features = ["OffscreenCanvas"] } +wgpu = { version = "26", default-features = false, features = ["serde", "webgpu", "wgsl"] } diff --git a/core/src/index.js b/core/index.js similarity index 56% rename from core/src/index.js rename to core/index.js index b04054a..a0b5832 100644 --- a/core/src/index.js +++ b/core/index.js @@ -1,9 +1,17 @@ -const types = { f32: Float32Array, u32: Uint32Array, i32: Int32Array }; +const views = Object.freeze({ f32: Float32Array, u32: Uint32Array, i32: Int32Array }); export class SharedRows { constructor(buffer, descriptor) { + if (!(buffer instanceof SharedArrayBuffer)) throw new TypeError("ROWS_DESCRIPTOR"); this.buffer = buffer; + this.update(descriptor); + } + + update(descriptor) { + if (!views[descriptor.format] || descriptor.name !== (this.descriptor?.name ?? descriptor.name)) + throw new TypeError("ROWS_DESCRIPTOR"); this.descriptor = Object.freeze(descriptor); + return this; } get name() { return this.descriptor.name; } @@ -11,16 +19,18 @@ export class SharedRows { get stride() { return this.descriptor.stride; } get format() { return this.descriptor.format; } get view() { - return new types[this.format](this.buffer, this.descriptor.offset, this.rows * this.stride / 4); + const View = views[this.format]; + return new View(this.buffer, this.descriptor.offset, this.descriptor.bytes / View.BYTES_PER_ELEMENT); } row(index) { if (!Number.isInteger(index) || index < 0 || index >= this.rows) throw new RangeError("ROW_RANGE"); - const width = this.stride / 4; + const width = this.stride / views[this.format].BYTES_PER_ELEMENT; return this.view.subarray(index * width, (index + 1) * width); } read(index) { return Array.from(this.row(index)); } + write(index, values) { const row = this.row(index); if (!values || values.length !== row.length) throw new RangeError("ROW_WIDTH"); @@ -49,30 +59,74 @@ export class YawnCore { this.#worker.addEventListener("messageerror", () => this.#fail("WORKER_ERROR")); this.#worker.start?.(); const offscreen = canvas.transferControlToOffscreen?.() ?? canvas; - this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]) - .then(({ buffer }) => { this.#buffer = buffer; }); + this.ready = this.#request("init", { canvas: offscreen, arenaBytes }, [offscreen]).then(result => { + this.#buffer = result.buffer; + for (const descriptor of result.rows) this.#arrays.set( + descriptor.name, + new SharedRows(this.#buffer, descriptor), + ); + }); } - async allocateRows({ name, rows, stride, format }) { + async createRows({ name, rows, stride, format }) { await this.ready; - const descriptor = await this.#request("allocate", { name, rows, stride, format }); - const array = new SharedRows(this.#buffer, descriptor); + const descriptor = await this.#request("create-rows", { name, rows, stride, format }); + const array = this.#arrays.get(name)?.update(descriptor) + ?? new SharedRows(this.#buffer, descriptor); this.#arrays.set(name, array); return array; } + async deleteRows(name) { + await this.ready; + await this.#request("delete-rows", { name }); + this.#arrays.delete(name); + } + + async allocateObject(name) { + await this.ready; + const { id, rows } = await this.#request("allocate-object", { name }); + this.#arrays.get(name).update(rows); + return id; + } + + async deleteObject(name, id) { + await this.ready; + return this.#request("delete-object", { name, id }); + } + + async compileGraph(serialized) { + await this.ready; + return this.#request("compile-graph", { serialized }); + } + + async switchLoadout(id) { + await this.ready; + return this.#request("switch-loadout", { id }); + } + + async play() { + await this.ready; + return this.#request("play"); + } + + async pause() { + await this.ready; + return this.#request("pause"); + } + + async setFps(fps) { + await this.ready; + return this.#request("set-fps", { fps }); + } + array(name) { const array = this.#arrays.get(name); if (!array) throw new Error(`UNKNOWN_ARRAY: ${name}`); return array; } - async loadGraph(serialized) { - await this.ready; - return this.#request("load-graph", { serialized }); - } - - #request(type, payload, transfer = []) { + #request(type, payload = {}, transfer = []) { const request = this.#next++; return new Promise((resolve, reject) => { this.#pending.set(request, { resolve, reject }); diff --git a/core/lib.rs b/core/lib.rs new file mode 100644 index 0000000..14db868 --- /dev/null +++ b/core/lib.rs @@ -0,0 +1,149 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use wasm_bindgen::prelude::*; + +#[path = "render_graph/compiler.rs"] +mod compiler; +#[path = "renderer/wgpu.rs"] +mod gpu; +#[path = "render_graph/gpu_resource.rs"] +mod gpu_resource; +#[path = "render_graph/render_graph.rs"] +mod graph; +#[path = "renderer/render.rs"] +mod render; +mod render_data; +#[path = "render_graph/store.rs"] +mod store; + +use gpu::Wgpu; +use render::RenderLoop; +use render_data::RenderData; +use store::Store; + +#[wasm_bindgen] +pub struct Core { + data: Rc>, + gpu: Rc>>, + store: Rc>, + render: Rc, +} + +#[wasm_bindgen] +impl Core { + #[wasm_bindgen(constructor)] + pub fn new(arena_bytes: u32) -> Result { + Ok(Self { + data: Rc::new(RefCell::new( + RenderData::new(arena_bytes).map_err(JsError::new)?, + )), + gpu: Rc::new(RefCell::new(None)), + store: Rc::new(RefCell::new(Store::default())), + render: Rc::new(RenderLoop::new()), + }) + } + + pub async fn initialize(&self, canvas: web_sys::OffscreenCanvas) -> Result<(), JsError> { + let gpu = Wgpu::new(canvas) + .await + .map_err(|error| JsError::new(&error))?; + *self.gpu.borrow_mut() = Some(gpu); + self.render + .start(self.gpu.clone(), self.store.clone(), self.data.clone()); + Ok(()) + } + + pub fn rows(&self) -> String { + serde_json::to_string(&self.data.borrow().descriptors()).unwrap() + } + + pub fn create_rows( + &self, + name: String, + rows: u32, + stride: u32, + format: String, + ) -> Result { + let rows = self + .data + .borrow_mut() + .create_rows(name, rows, stride, format) + .map_err(JsError::new)?; + if let Some(gpu) = self.gpu.borrow().as_ref() { + self.store + .borrow_mut() + .refresh_rows(&rows.name, gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error))?; + } + Ok(serde_json::to_string(&rows).unwrap()) + } + + pub fn delete_rows(&self, name: String) -> Result<(), JsError> { + if self.store.borrow().uses_rows(&name) { + return Err(JsError::new("ROWS_ACTIVE")); + } + self.data + .borrow_mut() + .delete_rows(&name) + .map_err(JsError::new) + } + + pub fn allocate_object(&self, name: &str) -> Result { + let (id, grew) = self + .data + .borrow_mut() + .allocate_object(name) + .map_err(JsError::new)?; + let rows = self + .data + .borrow() + .rows(name) + .ok_or_else(|| JsError::new("ROWS_UNKNOWN"))? + .clone(); + if grew { + if let Some(gpu) = self.gpu.borrow().as_ref() { + self.store + .borrow_mut() + .refresh_rows(name, gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error))?; + } + } + Ok(serde_json::json!({ "id": id, "rows": rows }).to_string()) + } + + pub fn delete_object(&self, name: &str, id: u32) -> Result<(), JsError> { + self.data + .borrow_mut() + .delete_object(name, id) + .map_err(JsError::new) + } + + pub fn compile_graph(&self, source: &str) -> Result { + let graph = compiler::compile(source).map_err(JsError::new)?; + Ok(self.store.borrow_mut().save(graph)) + } + + pub fn switch_loadout(&self, id: &str) -> Result<(), JsError> { + let gpu = self.gpu.borrow(); + let gpu = gpu + .as_ref() + .ok_or_else(|| JsError::new("WEBGPU_UNINITIALIZED"))?; + self.store + .borrow_mut() + .switch(id, gpu, &self.data.borrow()) + .map_err(|error| JsError::new(&error)) + } + + pub fn play(&self) { + self.render.play(); + } + + pub fn pause(&self) { + self.render.pause(); + } + + pub fn set_fps(&self, fps: u32) -> Result<(), JsError> { + self.render.set_fps(fps).map_err(JsError::new) + } +} diff --git a/core/package.json b/core/package.json index c4576f6..38d068d 100644 --- a/core/package.json +++ b/core/package.json @@ -3,6 +3,6 @@ "version": "0.1.0", "description": "Rust/WASM render data and render graph core", "type": "module", - "exports": "./src/index.js", - "files": ["src", "pkg"] + "exports": "./index.js", + "files": ["index.js", "worker.js", "pkg"] } diff --git a/core/render_data.rs b/core/render_data.rs new file mode 100644 index 0000000..0cef19d --- /dev/null +++ b/core/render_data.rs @@ -0,0 +1,265 @@ +use std::collections::{BTreeMap, HashMap, HashSet}; + +use serde::Serialize; + +const ALIGNMENT: u32 = 64; + +#[derive(Clone, Serialize)] +pub struct Rows { + pub name: String, + pub rows: u32, + pub stride: u32, + pub format: String, + pub offset: u32, + pub bytes: u32, +} + +#[derive(Default)] +struct Slots { + active: HashSet, + free: Vec, + next: u32, +} + +pub struct RenderData { + arena: Box<[u8]>, + arena_start: usize, + base: u32, + free: BTreeMap, + rows: HashMap, + slots: HashMap, +} + +impl RenderData { + pub fn new(capacity: u32) -> Result { + if capacity < ALIGNMENT || capacity > u32::MAX - (ALIGNMENT - 1) { + return Err("INIT"); + } + let mut arena = vec![0; (capacity + ALIGNMENT - 1) as usize].into_boxed_slice(); + let pointer = arena.as_mut_ptr() as usize; + let aligned = (pointer + ALIGNMENT as usize - 1) & !(ALIGNMENT as usize - 1); + let mut data = Self { + arena, + arena_start: aligned - pointer, + base: aligned as u32, + free: BTreeMap::from([(0, capacity)]), + rows: HashMap::new(), + slots: HashMap::new(), + }; + data.create_rows("info".into(), 1, 32, "f32".into())?; + Ok(data) + } + + pub fn create_rows( + &mut self, + name: String, + rows: u32, + stride: u32, + format: String, + ) -> Result { + if name.is_empty() + || rows == 0 + || stride < 16 + || stride % 16 != 0 + || !matches!(format.as_str(), "f32" | "u32" | "i32") + { + return Err("ROWS"); + } + if let Some(current) = self.rows.get(&name).cloned() { + if current.stride != stride || current.format != format { + return Err("ROWS"); + } + if rows <= current.rows { + return Ok(current); + } + if name == "info" { + return Err("ROWS_BUILTIN"); + } + return self.grow_rows(current, rows); + } + let bytes = rows.checked_mul(stride).ok_or("ARENA_OOM")?; + let offset = self.reserve(bytes)?; + let end = offset + bytes; + self.arena[self.arena_start + offset as usize..self.arena_start + end as usize].fill(0); + let descriptor = Rows { + name: name.clone(), + rows, + stride, + format, + offset: self.base + offset, + bytes, + }; + self.rows.insert(name, descriptor.clone()); + self.slots.insert(descriptor.name.clone(), Slots::default()); + Ok(descriptor) + } + + pub fn delete_rows(&mut self, name: &str) -> Result<(), &'static str> { + if name == "info" { + return Err("ROWS_BUILTIN"); + } + if self + .slots + .get(name) + .is_some_and(|slots| !slots.active.is_empty()) + { + return Err("ROWS_ACTIVE"); + } + let rows = self.rows.remove(name).ok_or("ROWS_UNKNOWN")?; + self.slots.remove(name); + self.release(rows.offset - self.base, rows.bytes); + Ok(()) + } + + pub fn rows(&self, name: &str) -> Option<&Rows> { + self.rows.get(name) + } + + pub fn descriptors(&self) -> Vec { + let mut rows = self.rows.values().cloned().collect::>(); + rows.sort_by_key(|rows| rows.offset); + rows + } + + pub fn bytes(&self, name: &str) -> Option<&[u8]> { + let rows = self.rows(name)?; + let start = self.arena_start + (rows.offset - self.base) as usize; + Some(&self.arena[start..start + rows.bytes as usize]) + } + + pub fn allocate_object(&mut self, name: &str) -> Result<(u32, bool), &'static str> { + if name == "info" { + return Err("ROWS_BUILTIN"); + } + let slots = self.slots.get(name).ok_or("ROWS_UNKNOWN")?; + let reused = slots.free.last().copied(); + let id = reused.unwrap_or(slots.next); + let next = id.checked_add(1).ok_or("OBJECT_LIMIT")?; + let capacity = self.rows[name].rows; + let grew = id >= capacity; + if grew { + let descriptor = self.rows[name].clone(); + self.grow_rows(descriptor, next)?; + } + let slots = self.slots.get_mut(name).unwrap(); + if reused.is_some() { + slots.free.pop(); + } else { + slots.next = next; + } + slots.active.insert(id); + Ok((id, grew)) + } + + pub fn delete_object(&mut self, name: &str, id: u32) -> Result<(), &'static str> { + let slots = self.slots.get_mut(name).ok_or("ROWS_UNKNOWN")?; + if !slots.active.remove(&id) { + return Err("OBJECT_UNKNOWN"); + } + let rows = self.rows[name].clone(); + let start = self.arena_start + (rows.offset - self.base + id * rows.stride) as usize; + self.arena[start..start + rows.stride as usize].fill(0); + slots.free.push(id); + Ok(()) + } + + fn grow_rows(&mut self, mut descriptor: Rows, rows: u32) -> Result { + let bytes = rows.checked_mul(descriptor.stride).ok_or("ARENA_OOM")?; + let old_offset = descriptor.offset - self.base; + let old_end = old_offset + descriptor.bytes; + let extra = bytes - descriptor.bytes; + if self + .free + .get(&old_end) + .is_some_and(|available| *available >= extra) + { + let available = self.free.remove(&old_end).unwrap(); + if available > extra { + self.free.insert(old_end + extra, available - extra); + } + self.arena[self.arena_start + old_end as usize + ..self.arena_start + (old_end + extra) as usize] + .fill(0); + } else { + let offset = self.reserve(bytes)?; + let start = self.arena_start + offset as usize; + self.arena[start..start + bytes as usize].fill(0); + self.arena.copy_within( + self.arena_start + old_offset as usize..self.arena_start + old_end as usize, + start, + ); + self.release(old_offset, descriptor.bytes); + descriptor.offset = self.base + offset; + } + descriptor.rows = rows; + descriptor.bytes = bytes; + self.rows + .insert(descriptor.name.clone(), descriptor.clone()); + Ok(descriptor) + } + + pub fn update_info(&mut self, delta: f32, frame: u32, elapsed: f32, fps: u32) { + let start = self.arena_start + (self.rows["info"].offset - self.base) as usize; + for (index, value) in [delta, frame as f32, elapsed, fps as f32] + .iter() + .enumerate() + { + self.arena[start + index * 4..start + index * 4 + 4] + .copy_from_slice(&value.to_le_bytes()); + } + } + + pub fn skip_render(&self) -> bool { + let rows = &self.rows["info"]; + let start = self.arena_start + (rows.offset - self.base) as usize + 16; + f32::from_le_bytes(self.arena[start..start + 4].try_into().unwrap()) == 1.0 + } + + fn reserve(&mut self, bytes: u32) -> Result { + let (block, block_bytes, offset) = self + .free + .iter() + .find_map(|(&block, &block_bytes)| { + let offset = align(block)?; + (offset.checked_add(bytes)? <= block.checked_add(block_bytes)?).then_some(( + block, + block_bytes, + offset, + )) + }) + .ok_or("ARENA_OOM")?; + self.free.remove(&block); + if offset > block { + self.free.insert(block, offset - block); + } + let end = offset + bytes; + let block_end = block + block_bytes; + if end < block_end { + self.free.insert(end, block_end - end); + } + Ok(offset) + } + + fn release(&mut self, mut offset: u32, mut bytes: u32) { + if let Some((&previous, &previous_bytes)) = self.free.range(..offset).next_back() { + if previous + previous_bytes == offset { + self.free.remove(&previous); + offset = previous; + bytes += previous_bytes; + } + } + if let Some((&next, &next_bytes)) = self.free.range(offset..).next() { + if offset + bytes == next { + self.free.remove(&next); + bytes += next_bytes; + } + } + self.free.insert(offset, bytes); + } +} + +fn align(value: u32) -> Option { + value + .checked_add(ALIGNMENT - 1) + .map(|value| value & !(ALIGNMENT - 1)) +} diff --git a/core/render_graph/compiler.rs b/core/render_graph/compiler.rs new file mode 100644 index 0000000..6a7071b --- /dev/null +++ b/core/render_graph/compiler.rs @@ -0,0 +1,162 @@ +use serde_json::{Map, Value}; + +use crate::graph::RenderGraph; + +pub fn compile(source: &str) -> Result { + let Expression::List(mut root) = Parser::parse(source)? else { + return Err("GRAPH_WIRE"); + }; + if root.len() != 3 + || !matches!(&root[0], Expression::Atom(Value::String(tag)) if tag == "yawn-graph") + || !matches!(&root[1], Expression::Atom(Value::Number(version)) if version.as_u64() == Some(1)) + { + return Err("GRAPH_WIRE"); + } + let value = decode(root.pop().unwrap())?; + let mut graph: RenderGraph = serde_json::from_value(value).map_err(|_| "GRAPH_SHAPE")?; + graph.prepare()?; + Ok(graph) +} + +enum Expression { + Atom(Value), + List(Vec), +} + +struct Parser<'a> { + source: &'a [u8], + at: usize, +} + +impl<'a> Parser<'a> { + fn parse(source: &'a str) -> Result { + let mut parser = Self { + source: source.as_bytes(), + at: 0, + }; + let expression = parser.expression()?; + parser.whitespace(); + (parser.at == parser.source.len()) + .then_some(expression) + .ok_or("GRAPH_WIRE") + } + + fn expression(&mut self) -> Result { + self.whitespace(); + match self.source.get(self.at) { + Some(b'(') => self.list(), + Some(b'"') => self.string(), + Some(b')') | None => Err("GRAPH_WIRE"), + Some(_) => self.atom(), + } + } + + fn list(&mut self) -> Result { + self.at += 1; + let mut values = Vec::new(); + loop { + self.whitespace(); + match self.source.get(self.at) { + Some(b')') => { + self.at += 1; + return Ok(Expression::List(values)); + } + None => return Err("GRAPH_WIRE"), + _ => values.push(self.expression()?), + } + } + } + + fn string(&mut self) -> Result { + let start = self.at; + self.at += 1; + let mut escaped = false; + while let Some(&byte) = self.source.get(self.at) { + self.at += 1; + if escaped { + escaped = false; + } else if byte == b'\\' { + escaped = true; + } else if byte == b'"' { + let value = serde_json::from_slice(&self.source[start..self.at]) + .map_err(|_| "GRAPH_WIRE")?; + return Ok(Expression::Atom(Value::String(value))); + } + } + Err("GRAPH_WIRE") + } + + fn atom(&mut self) -> Result { + let start = self.at; + while self + .source + .get(self.at) + .is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')')) + { + self.at += 1; + } + let token = std::str::from_utf8(&self.source[start..self.at]).map_err(|_| "GRAPH_WIRE")?; + let value = match token { + "true" => Value::Bool(true), + "false" => Value::Bool(false), + "null" => Value::Null, + _ => serde_json::from_str::(token) + .ok() + .filter(Value::is_number) + .unwrap_or_else(|| Value::String(token.into())), + }; + Ok(Expression::Atom(value)) + } + + fn whitespace(&mut self) { + while self + .source + .get(self.at) + .is_some_and(u8::is_ascii_whitespace) + { + self.at += 1; + } + } +} + +fn decode(expression: Expression) -> Result { + let Expression::List(mut values) = expression else { + return match expression { + Expression::Atom(value) => Ok(value), + Expression::List(_) => unreachable!(), + }; + }; + if values.is_empty() { + return Err("GRAPH_WIRE"); + } + let tag = match values.remove(0) { + Expression::Atom(Value::String(tag)) => tag, + _ => return Err("GRAPH_WIRE"), + }; + if tag == "array" { + return values.into_iter().map(decode).collect(); + } + if tag != "object" { + return Err("GRAPH_WIRE"); + } + let mut object = Map::new(); + for field in values { + let Expression::List(mut field) = field else { + return Err("GRAPH_WIRE"); + }; + if field.len() != 3 + || !matches!(&field[0], Expression::Atom(Value::String(tag)) if tag == "field") + { + return Err("GRAPH_WIRE"); + } + let value = decode(field.pop().unwrap())?; + let key = match field.pop().unwrap() { + Expression::Atom(Value::String(key)) => key, + _ => return Err("GRAPH_WIRE"), + }; + if object.insert(key, value).is_some() { + return Err("GRAPH_WIRE"); + } + } + Ok(Value::Object(object)) +} diff --git a/core/render_graph/gpu_resource.rs b/core/render_graph/gpu_resource.rs new file mode 100644 index 0000000..a058063 --- /dev/null +++ b/core/render_graph/gpu_resource.rs @@ -0,0 +1,545 @@ +use std::collections::{BTreeMap, HashMap}; +use std::num::NonZeroU64; + +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::gpu::Wgpu; +use crate::graph::{Binding, Extent, Pass, RenderGraph, RenderPipeline, Texture}; +use crate::render_data::RenderData; + +pub struct GpuResources { + pub buffers: HashMap, + pub textures: Vec, + pub texture_slots: HashMap, + pub samplers: HashMap, + pub render_pipelines: HashMap, + pub compute_pipelines: HashMap, + pub passes: Vec, +} + +pub struct GpuBuffer { + pub buffer: wgpu::Buffer, + pub source: String, +} + +pub struct GpuTexture { + pub _texture: wgpu::Texture, + pub view: wgpu::TextureView, +} + +pub enum GpuPass { + Render(wgpu::RenderBundle), + Compute { + pipeline: wgpu::ComputePipeline, + bind_groups: Vec<(u32, wgpu::BindGroup)>, + }, +} + +impl GpuResources { + pub fn activate(graph: &RenderGraph, gpu: &Wgpu, data: &RenderData) -> Result { + let mut buffers = HashMap::new(); + for source in &graph.resources.buffers { + let rows = data.rows(&source.array).ok_or("GRAPH_ARRAY_UNKNOWN")?; + let buffer = gpu.device.create_buffer(&wgpu::BufferDescriptor { + label: Some(&source.id), + size: u64::from(rows.bytes.max(4)), + usage: buffer_usage(&source.usage)?, + mapped_at_creation: false, + }); + gpu.queue + .write_buffer(&buffer, 0, data.bytes(&source.array).unwrap()); + buffers.insert( + source.id.clone(), + GpuBuffer { + buffer, + source: source.array.clone(), + }, + ); + } + + let mut textures = Vec::new(); + let mut physical_slots = HashMap::new(); + let mut texture_slots = HashMap::new(); + for source in &graph.resources.textures { + let physical = match physical_slots.get(&source.slot) { + Some(&physical) => physical, + None => { + let descriptor = texture_descriptor(source, gpu.width, gpu.height)?; + let texture = gpu.device.create_texture(&descriptor); + let physical = textures.len(); + textures.push(GpuTexture { + view: texture.create_view(&wgpu::TextureViewDescriptor::default()), + _texture: texture, + }); + physical_slots.insert(source.slot, physical); + physical + } + }; + texture_slots.insert(source.id.clone(), physical); + } + + let mut samplers = HashMap::new(); + for source in &graph.resources.samplers { + samplers.insert( + source.id.clone(), + gpu.device + .create_sampler(&sampler_descriptor(&source.id, &source.descriptor)?), + ); + } + + let render_pipelines = graph + .pipelines + .render + .iter() + .map(|source| { + create_render_pipeline(source, gpu).map(|pipeline| (source.id.clone(), pipeline)) + }) + .collect::, _>>()?; + let compute_pipelines = graph + .pipelines + .compute + .iter() + .map(|source| { + let module = gpu + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&source.id), + source: wgpu::ShaderSource::Wgsl(source.code.clone().into()), + }); + let pipeline = + gpu.device + .create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(&source.id), + layout: None, + module: &module, + entry_point: Some(&source.entry), + compilation_options: Default::default(), + cache: None, + }); + Ok::<_, String>((source.id.clone(), pipeline)) + }) + .collect::, _>>()?; + + let mut resources = Self { + buffers, + textures, + texture_slots, + samplers, + render_pipelines, + compute_pipelines, + passes: Vec::new(), + }; + for pass in &graph.passes { + let compiled = match pass.kind.as_str() { + "render" => GpuPass::Render(resources.render_bundle(graph, pass, gpu)?), + "compute" => { + let pipeline = resources + .compute_pipelines + .get(&pass.pipeline) + .ok_or("GRAPH_PIPELINE")? + .clone(); + let bind_groups = resources.bind_groups( + pass, + |group| pipeline.get_bind_group_layout(group), + gpu, + )?; + GpuPass::Compute { + pipeline, + bind_groups, + } + } + _ => return Err("GRAPH_PASS".into()), + }; + resources.passes.push(compiled); + } + Ok(resources) + } + + pub fn texture_view(&self, id: &str) -> Option<&wgpu::TextureView> { + self.texture_slots + .get(id) + .and_then(|slot| self.textures.get(*slot)) + .map(|texture| &texture.view) + } + + fn render_bundle( + &self, + graph: &RenderGraph, + pass: &Pass, + gpu: &Wgpu, + ) -> Result { + let pipeline = self + .render_pipelines + .get(&pass.pipeline) + .ok_or("GRAPH_PIPELINE")?; + let declaration = graph + .pipelines + .render + .iter() + .find(|pipeline| pipeline.id == pass.pipeline) + .ok_or("GRAPH_PIPELINE")?; + let color_formats = pass + .color + .iter() + .map(|attachment| attachment_format(graph, &attachment.resource, gpu.format).map(Some)) + .collect::, _>>()?; + let depth_stencil = pass + .depth + .as_ref() + .map(|attachment| { + Ok::<_, String>(wgpu::RenderBundleDepthStencil { + format: attachment_format(graph, &attachment.resource, gpu.format)?, + depth_read_only: false, + stencil_read_only: true, + }) + }) + .transpose()?; + let mut encoder = + gpu.device + .create_render_bundle_encoder(&wgpu::RenderBundleEncoderDescriptor { + label: Some(&pass.id), + color_formats: &color_formats, + depth_stencil, + sample_count: multisample(&declaration.multisample)?.count, + multiview: None, + }); + encoder.set_pipeline(pipeline); + for (group, bind_group) in + self.bind_groups(pass, |group| pipeline.get_bind_group_layout(group), gpu)? + { + encoder.set_bind_group(group, &bind_group, &[]); + } + for binding in &pass.vertex_buffers { + let buffer = &self + .buffers + .get(&binding.resource) + .ok_or("GRAPH_RESOURCE_UNKNOWN")? + .buffer; + encoder.set_vertex_buffer(binding.slot, buffer.slice(binding.offset..)); + } + if let Some(binding) = &pass.index_buffer { + let buffer = &self + .buffers + .get(&binding.resource) + .ok_or("GRAPH_RESOURCE_UNKNOWN")? + .buffer; + encoder.set_index_buffer( + buffer.slice(binding.offset..), + parse(&binding.format, "GRAPH_INDEX_FORMAT")?, + ); + encoder.draw_indexed( + pass.draw.first_index..pass.draw.first_index + pass.draw.indices, + pass.draw.base_vertex, + pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, + ); + } else { + encoder.draw( + pass.draw.first_vertex..pass.draw.first_vertex + pass.draw.vertices, + pass.draw.first_instance..pass.draw.first_instance + pass.draw.instances, + ); + } + Ok(encoder.finish(&wgpu::RenderBundleDescriptor { + label: Some(&pass.id), + })) + } + + fn bind_groups( + &self, + pass: &Pass, + layout: impl Fn(u32) -> wgpu::BindGroupLayout, + gpu: &Wgpu, + ) -> Result, String> { + let mut groups: BTreeMap> = BTreeMap::new(); + for binding in &pass.bindings { + groups.entry(binding.group).or_default().push(binding); + } + groups + .into_iter() + .map(|(group, bindings)| { + let entries = bindings + .into_iter() + .map(|binding| { + let resource = if let Some(buffer) = self.buffers.get(&binding.resource) { + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &buffer.buffer, + offset: binding.offset, + size: binding.size.and_then(NonZeroU64::new), + }) + } else if let Some(view) = self.texture_view(&binding.resource) { + wgpu::BindingResource::TextureView(view) + } else if let Some(sampler) = self.samplers.get(&binding.resource) { + wgpu::BindingResource::Sampler(sampler) + } else { + return Err("GRAPH_RESOURCE_UNKNOWN".into()); + }; + Ok(wgpu::BindGroupEntry { + binding: binding.binding, + resource, + }) + }) + .collect::, String>>()?; + let bind_group = gpu.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(&pass.id), + layout: &layout(group), + entries: &entries, + }); + Ok((group, bind_group)) + }) + .collect() + } +} + +fn create_render_pipeline( + source: &RenderPipeline, + gpu: &Wgpu, +) -> Result { + let module = gpu + .device + .create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(&source.id), + source: wgpu::ShaderSource::Wgsl(source.code.clone().into()), + }); + let attributes = source + .vertex + .buffers + .iter() + .map(|buffer| { + buffer + .attributes + .iter() + .map(|attribute| { + Ok(wgpu::VertexAttribute { + format: parse(&attribute.format, "GRAPH_VERTEX_FORMAT")?, + offset: attribute.offset, + shader_location: attribute.shader_location, + }) + }) + .collect::, String>>() + }) + .collect::, _>>()?; + let layouts = source + .vertex + .buffers + .iter() + .zip(&attributes) + .map(|(buffer, attributes)| { + Ok(wgpu::VertexBufferLayout { + array_stride: buffer.array_stride, + step_mode: parse(&buffer.step_mode, "GRAPH_VERTEX_STEP")?, + attributes, + }) + }) + .collect::, String>>()?; + let targets = source + .fragment + .targets + .iter() + .map(|target| { + let format = if target.format == "canvas" { + gpu.format + } else { + parse(&target.format, "GRAPH_TEXTURE_FORMAT")? + }; + let blend = (!target.blend.is_null()) + .then(|| { + serde_json::from_value::(target.blend.clone()) + .map_err(|_| String::from("GRAPH_BLEND")) + }) + .transpose()?; + let write_mask = match target.write_mask { + Some(bits) => wgpu::ColorWrites::from_bits(bits).ok_or("GRAPH_WRITE_MASK")?, + None => wgpu::ColorWrites::ALL, + }; + Ok(Some(wgpu::ColorTargetState { + format, + blend, + write_mask, + })) + }) + .collect::, String>>()?; + Ok(gpu + .device + .create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(&source.id), + layout: None, + vertex: wgpu::VertexState { + module: &module, + entry_point: Some(&source.vertex.entry), + compilation_options: Default::default(), + buffers: &layouts, + }, + primitive: primitive(&source.primitive)?, + depth_stencil: depth_stencil(&source.depth_stencil)?, + multisample: multisample(&source.multisample)?, + fragment: Some(wgpu::FragmentState { + module: &module, + entry_point: Some(&source.fragment.entry), + compilation_options: Default::default(), + targets: &targets, + }), + multiview: None, + cache: None, + })) +} + +fn buffer_usage(names: &[String]) -> Result { + names + .iter() + .try_fold(wgpu::BufferUsages::COPY_DST, |usage, name| { + Ok(usage + | match name.as_str() { + "uniform" => wgpu::BufferUsages::UNIFORM, + "storage" => wgpu::BufferUsages::STORAGE, + "vertex" => wgpu::BufferUsages::VERTEX, + "index" => wgpu::BufferUsages::INDEX, + "indirect" => wgpu::BufferUsages::INDIRECT, + "copySrc" => wgpu::BufferUsages::COPY_SRC, + _ => return Err("GRAPH_BUFFER_USAGE".into()), + }) + }) +} + +fn texture_usage(names: &[String]) -> Result { + names + .iter() + .try_fold(wgpu::TextureUsages::empty(), |usage, name| { + Ok(usage + | match name.as_str() { + "render" => wgpu::TextureUsages::RENDER_ATTACHMENT, + "sampled" => wgpu::TextureUsages::TEXTURE_BINDING, + "storage" => wgpu::TextureUsages::STORAGE_BINDING, + "copySrc" => wgpu::TextureUsages::COPY_SRC, + "copyDst" => wgpu::TextureUsages::COPY_DST, + _ => return Err("GRAPH_TEXTURE_USAGE".into()), + }) + }) +} + +fn texture_descriptor( + source: &Texture, + width: u32, + height: u32, +) -> Result, String> { + let value = |index, canvas| -> Result { + match source.size.get(index) { + Some(Extent::Pixels(value)) => Ok(*value), + Some(Extent::Canvas(value)) if value == "canvas" => Ok(canvas), + Some(Extent::Canvas(_)) => Err("GRAPH_TEXTURE_SIZE".into()), + None => Ok(canvas), + } + }; + Ok(wgpu::TextureDescriptor { + label: Some(&source.id), + size: wgpu::Extent3d { + width: value(0, width)?, + height: value(1, height)?, + depth_or_array_layers: value(2, 1)?, + }, + mip_level_count: source.mip_level_count, + sample_count: source.sample_count, + dimension: parse(&source.dimension, "GRAPH_TEXTURE_DIMENSION")?, + format: parse(&source.format, "GRAPH_TEXTURE_FORMAT")?, + usage: texture_usage(&source.usage)?, + view_formats: &[], + }) +} + +fn attachment_format( + graph: &RenderGraph, + id: &str, + surface: wgpu::TextureFormat, +) -> Result { + if id == "canvas" { + return Ok(surface); + } + graph + .resources + .textures + .iter() + .find(|texture| texture.id == id) + .ok_or_else(|| "GRAPH_ATTACHMENT".into()) + .and_then(|texture| parse(&texture.format, "GRAPH_TEXTURE_FORMAT")) +} + +fn primitive(value: &Value) -> Result { + if value.is_null() { + return Ok(Default::default()); + } + serde_json::from_value(value.clone()).map_err(|_| "GRAPH_PRIMITIVE".into()) +} + +fn depth_stencil(value: &Value) -> Result, String> { + if value.is_null() { + return Ok(None); + } + serde_json::from_value(value.clone()) + .map(Some) + .map_err(|_| "GRAPH_DEPTH_STENCIL".into()) +} + +fn multisample(value: &Value) -> Result { + if value.is_null() { + return Ok(Default::default()); + } + let object = value.as_object().ok_or("GRAPH_MULTISAMPLE")?; + Ok(wgpu::MultisampleState { + count: object.get("count").and_then(Value::as_u64).unwrap_or(1) as u32, + mask: object + .get("mask") + .and_then(Value::as_u64) + .unwrap_or(u64::MAX), + alpha_to_coverage_enabled: object + .get("alphaToCoverageEnabled") + .and_then(Value::as_bool) + .unwrap_or(false), + }) +} + +fn sampler_descriptor<'a>( + label: &'a str, + value: &Value, +) -> Result, String> { + let mut descriptor = wgpu::SamplerDescriptor { + label: Some(label), + ..Default::default() + }; + let Some(object) = value.as_object() else { + return Ok(descriptor); + }; + macro_rules! enum_field { + ($json:literal, $field:ident, $code:literal) => { + if let Some(value) = object.get($json).and_then(Value::as_str) { + descriptor.$field = parse(value, $code)?; + } + }; + } + enum_field!("addressModeU", address_mode_u, "GRAPH_SAMPLER"); + enum_field!("addressModeV", address_mode_v, "GRAPH_SAMPLER"); + enum_field!("addressModeW", address_mode_w, "GRAPH_SAMPLER"); + enum_field!("magFilter", mag_filter, "GRAPH_SAMPLER"); + enum_field!("minFilter", min_filter, "GRAPH_SAMPLER"); + enum_field!("mipmapFilter", mipmap_filter, "GRAPH_SAMPLER"); + descriptor.lod_min_clamp = object + .get("lodMinClamp") + .and_then(Value::as_f64) + .unwrap_or(0.0) as f32; + descriptor.lod_max_clamp = object + .get("lodMaxClamp") + .and_then(Value::as_f64) + .unwrap_or(32.0) as f32; + descriptor.compare = object + .get("compare") + .and_then(Value::as_str) + .map(|value| parse(value, "GRAPH_SAMPLER")) + .transpose()?; + descriptor.anisotropy_clamp = object + .get("anisotropyClamp") + .and_then(Value::as_u64) + .unwrap_or(1) as u16; + Ok(descriptor) +} + +fn parse(value: &str, code: &str) -> Result { + serde_json::from_value(Value::String(value.into())).map_err(|_| code.into()) +} diff --git a/core/render_graph/render_graph.rs b/core/render_graph/render_graph.rs new file mode 100644 index 0000000..b2efe27 --- /dev/null +++ b/core/render_graph/render_graph.rs @@ -0,0 +1,500 @@ +use std::collections::{HashMap, HashSet}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenderGraph { + pub id: String, + #[serde(default)] + pub resources: ResourceDeclarations, + #[serde(default)] + pub pipelines: PipelineDeclarations, + pub passes: Vec, +} + +#[derive(Clone, Default, Deserialize)] +pub struct ResourceDeclarations { + #[serde(default)] + pub buffers: Vec, + #[serde(default)] + pub textures: Vec, + #[serde(default)] + pub samplers: Vec, +} + +#[derive(Clone, Deserialize)] +pub struct Buffer { + pub id: String, + pub array: String, + #[serde(default)] + pub usage: Vec, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Texture { + pub id: String, + #[serde(default)] + pub size: Vec, + pub format: String, + #[serde(default)] + pub usage: Vec, + #[serde(default = "one")] + pub mip_level_count: u32, + #[serde(default = "one")] + pub sample_count: u32, + #[serde(default = "dimension")] + pub dimension: String, + #[serde(default = "yes")] + pub transient: bool, + #[serde(skip)] + pub slot: usize, +} + +#[derive(Clone, Deserialize, Serialize)] +#[serde(untagged)] +pub enum Extent { + Pixels(u32), + Canvas(String), +} + +#[derive(Clone, Deserialize)] +pub struct Sampler { + pub id: String, + #[serde(default)] + pub descriptor: Value, +} + +#[derive(Clone, Default, Deserialize)] +pub struct PipelineDeclarations { + #[serde(default)] + pub render: Vec, + #[serde(default)] + pub compute: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RenderPipeline { + pub id: String, + pub code: String, + #[serde(default)] + pub vertex: VertexStage, + #[serde(default)] + pub fragment: FragmentStage, + #[serde(default)] + pub primitive: Value, + #[serde(default)] + pub depth_stencil: Value, + #[serde(default)] + pub multisample: Value, +} + +#[derive(Clone, Deserialize)] +pub struct ComputePipeline { + pub id: String, + pub code: String, + #[serde(default = "compute_entry")] + pub entry: String, +} + +#[derive(Clone, Deserialize)] +pub struct VertexStage { + #[serde(default = "vertex_entry")] + pub entry: String, + #[serde(default)] + pub buffers: Vec, +} + +impl Default for VertexStage { + fn default() -> Self { + Self { + entry: vertex_entry(), + buffers: Vec::new(), + } + } +} + +#[derive(Clone, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VertexBuffer { + pub array_stride: u64, + #[serde(default = "vertex_step")] + pub step_mode: String, + #[serde(default)] + pub attributes: Vec, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VertexAttribute { + pub format: String, + pub offset: u64, + pub shader_location: u32, +} + +#[derive(Clone, Deserialize)] +pub struct FragmentStage { + #[serde(default = "fragment_entry")] + pub entry: String, + #[serde(default)] + pub targets: Vec, +} + +impl Default for FragmentStage { + fn default() -> Self { + Self { + entry: fragment_entry(), + targets: Vec::new(), + } + } +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FragmentTarget { + pub format: String, + #[serde(default)] + pub blend: Value, + pub write_mask: Option, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Pass { + pub id: String, + #[serde(rename = "type")] + pub kind: String, + pub pipeline: String, + #[serde(default)] + pub after: Vec, + #[serde(skip)] + pub dependencies: Vec, + #[serde(default)] + pub bindings: Vec, + #[serde(default)] + pub color: Vec, + pub depth: Option, + #[serde(default)] + pub vertex_buffers: Vec, + pub index_buffer: Option, + #[serde(default)] + pub draw: Draw, + #[serde(default = "dispatch")] + pub dispatch: [u32; 3], +} + +#[derive(Clone, Deserialize)] +pub struct Binding { + #[serde(default)] + pub group: u32, + pub binding: u32, + pub resource: String, + #[serde(default)] + pub offset: u64, + pub size: Option, +} + +#[derive(Clone, Deserialize)] +pub struct ColorAttachment { + pub resource: String, + #[serde(default)] + pub clear: Vec, + #[serde(default = "clear_op")] + pub load: String, + #[serde(default = "store_op")] + pub store: String, +} + +#[derive(Clone, Deserialize)] +pub struct DepthAttachment { + pub resource: String, + #[serde(default = "one_f32")] + pub clear: f32, + #[serde(default = "clear_op")] + pub load: String, + #[serde(default = "store_op")] + pub store: String, +} + +#[derive(Clone, Deserialize)] +pub struct VertexBinding { + #[serde(default)] + pub slot: u32, + pub resource: String, + #[serde(default)] + pub offset: u64, +} + +#[derive(Clone, Deserialize)] +pub struct IndexBinding { + pub resource: String, + #[serde(default = "index_format")] + pub format: String, + #[serde(default)] + pub offset: u64, +} + +#[derive(Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Draw { + #[serde(default = "three")] + pub vertices: u32, + #[serde(default)] + pub indices: u32, + #[serde(default = "one")] + pub instances: u32, + #[serde(default)] + pub first_vertex: u32, + #[serde(default)] + pub first_index: u32, + #[serde(default)] + pub base_vertex: i32, + #[serde(default)] + pub first_instance: u32, +} + +impl Default for Draw { + fn default() -> Self { + Self { + vertices: 3, + indices: 0, + instances: 1, + first_vertex: 0, + first_index: 0, + base_vertex: 0, + first_instance: 0, + } + } +} + +impl RenderGraph { + pub fn prepare(&mut self) -> Result<(), &'static str> { + if self.id.is_empty() || self.passes.is_empty() { + return Err("GRAPH_SHAPE"); + } + let mut ids = HashMap::new(); + for (index, pass) in self.passes.iter().enumerate() { + if pass.id.is_empty() || ids.insert(pass.id.clone(), index).is_some() { + return Err("GRAPH_PASS"); + } + } + let dependencies = self + .passes + .iter() + .map(|pass| { + pass.after + .iter() + .map(|id| ids.get(id).copied().ok_or("GRAPH_DEPENDENCY")) + .collect::, _>>() + }) + .collect::, _>>()?; + let mut emitted = vec![false; self.passes.len()]; + let mut order = Vec::with_capacity(self.passes.len()); + while order.len() < self.passes.len() { + let ready = (0..self.passes.len()).find(|&index| { + !emitted[index] + && dependencies[index] + .iter() + .all(|dependency| emitted[*dependency]) + }); + let index = ready.ok_or("GRAPH_CYCLE")?; + emitted[index] = true; + order.push(index); + } + let original = self.passes.clone(); + self.passes = order + .into_iter() + .map(|index| original[index].clone()) + .collect(); + let sorted_ids: HashMap<_, _> = self + .passes + .iter() + .enumerate() + .map(|(index, pass)| (pass.id.clone(), index)) + .collect(); + for pass in &mut self.passes { + pass.dependencies = pass.after.iter().map(|id| sorted_ids[id]).collect(); + } + self.plan_resources() + } + + fn plan_resources(&mut self) -> Result<(), &'static str> { + let mut used = HashSet::new(); + let mut lifetimes = HashMap::new(); + let mut render_pipelines = HashSet::new(); + let mut compute_pipelines = HashSet::new(); + for (frame, pass) in self.passes.iter().enumerate() { + match pass.kind.as_str() { + "render" => _ = render_pipelines.insert(pass.pipeline.clone()), + "compute" => _ = compute_pipelines.insert(pass.pipeline.clone()), + _ => return Err("GRAPH_PASS"), + } + for id in pass.resources() { + used.insert(id.to_owned()); + lifetimes + .entry(id.to_owned()) + .and_modify(|range: &mut (usize, usize)| range.1 = frame) + .or_insert((frame, frame)); + } + } + self.resources + .buffers + .retain(|value| used.contains(&value.id)); + self.resources + .samplers + .retain(|value| used.contains(&value.id)); + self.pipelines + .render + .retain(|value| render_pipelines.contains(&value.id)); + self.pipelines + .compute + .retain(|value| compute_pipelines.contains(&value.id)); + + let mut textures = self + .resources + .textures + .drain(..) + .filter_map(|texture| { + lifetimes + .get(&texture.id) + .copied() + .map(|range| (texture, range)) + }) + .collect::>(); + textures.sort_by_key(|value| value.1 .0); + let mut slots: Vec<(String, usize, bool)> = Vec::new(); + for (texture, (first, last)) in &mut textures { + let key = texture.key()?; + let reusable = texture + .transient + .then(|| { + slots + .iter() + .position(|slot| slot.2 && slot.0 == key && slot.1 < *first) + }) + .flatten(); + texture.slot = match reusable { + Some(slot) => { + slots[slot].1 = *last; + slot + } + None => { + slots.push((key, *last, texture.transient)); + slots.len() - 1 + } + }; + } + self.resources.textures = textures.into_iter().map(|value| value.0).collect(); + self.validate_ids() + } + + fn validate_ids(&self) -> Result<(), &'static str> { + let mut resources = HashSet::new(); + for id in self + .resources + .buffers + .iter() + .map(|value| &value.id) + .chain(self.resources.textures.iter().map(|value| &value.id)) + .chain(self.resources.samplers.iter().map(|value| &value.id)) + { + if id.is_empty() || !resources.insert(id) { + return Err("GRAPH_RESOURCE"); + } + } + let mut pipelines = HashSet::new(); + for id in self + .pipelines + .render + .iter() + .map(|value| &value.id) + .chain(self.pipelines.compute.iter().map(|value| &value.id)) + { + if id.is_empty() || !pipelines.insert(id) { + return Err("GRAPH_PIPELINE"); + } + } + Ok(()) + } +} + +impl Pass { + fn resources(&self) -> Vec<&str> { + self.bindings + .iter() + .map(|value| value.resource.as_str()) + .chain(self.color.iter().map(|value| value.resource.as_str())) + .chain(self.depth.iter().map(|value| value.resource.as_str())) + .chain( + self.vertex_buffers + .iter() + .map(|value| value.resource.as_str()), + ) + .chain( + self.index_buffer + .iter() + .map(|value| value.resource.as_str()), + ) + .collect() + } +} + +impl Texture { + fn key(&self) -> Result { + let mut usage = self.usage.clone(); + usage.sort_unstable(); + usage.dedup(); + serde_json::to_string(&( + &self.size, + &self.format, + usage, + self.mip_level_count, + self.sample_count, + &self.dimension, + )) + .map_err(|_| "GRAPH_RESOURCE") + } +} + +fn one() -> u32 { + 1 +} +fn three() -> u32 { + 3 +} +fn one_f32() -> f32 { + 1.0 +} +fn yes() -> bool { + true +} +fn dimension() -> String { + "2d".into() +} +fn vertex_entry() -> String { + "vertex".into() +} +fn fragment_entry() -> String { + "fragment".into() +} +fn compute_entry() -> String { + "main".into() +} +fn vertex_step() -> String { + "vertex".into() +} +fn clear_op() -> String { + "clear".into() +} +fn store_op() -> String { + "store".into() +} +fn index_format() -> String { + "uint32".into() +} +fn dispatch() -> [u32; 3] { + [1, 1, 1] +} diff --git a/core/render_graph/store.rs b/core/render_graph/store.rs new file mode 100644 index 0000000..2ef29f4 --- /dev/null +++ b/core/render_graph/store.rs @@ -0,0 +1,62 @@ +use std::collections::HashMap; + +use crate::gpu::Wgpu; +use crate::gpu_resource::GpuResources; +use crate::graph::RenderGraph; +use crate::render_data::RenderData; + +pub struct Loadout { + pub graph: RenderGraph, + pub resources: GpuResources, +} + +#[derive(Default)] +pub struct Store { + graphs: HashMap, + active: Option, +} + +impl Store { + pub fn save(&mut self, graph: RenderGraph) -> String { + let id = graph.id.clone(); + self.graphs.insert(id.clone(), graph); + id + } + + pub fn switch(&mut self, id: &str, gpu: &Wgpu, data: &RenderData) -> Result<(), String> { + let graph = self.graphs.get(id).ok_or("GRAPH_UNKNOWN")?.clone(); + let resources = GpuResources::activate(&graph, gpu, data)?; + self.active = Some(Loadout { graph, resources }); + Ok(()) + } + + pub fn active_mut(&mut self) -> Option<&mut Loadout> { + self.active.as_mut() + } + + pub fn uses_rows(&self, name: &str) -> bool { + self.active.as_ref().is_some_and(|active| { + active + .graph + .resources + .buffers + .iter() + .any(|buffer| buffer.array == name) + }) + } + + pub fn refresh_rows( + &mut self, + name: &str, + gpu: &Wgpu, + data: &RenderData, + ) -> Result<(), String> { + if !self.uses_rows(name) { + return Ok(()); + } + let graph = self.active.as_ref().unwrap().graph.clone(); + let resources = GpuResources::activate(&graph, gpu, data)?; + self.active = Some(Loadout { graph, resources }); + Ok(()) + } +} diff --git a/core/renderer/render.rs b/core/renderer/render.rs new file mode 100644 index 0000000..a85b5e0 --- /dev/null +++ b/core/renderer/render.rs @@ -0,0 +1,233 @@ +use std::cell::{Cell, RefCell}; +use std::rc::Rc; + +use gloo_timers::future::TimeoutFuture; + +use crate::gpu::Wgpu; +use crate::gpu_resource::GpuPass; +use crate::graph::{ColorAttachment, DepthAttachment}; +use crate::render_data::RenderData; +use crate::store::{Loadout, Store}; + +pub struct RenderLoop { + playing: Cell, + fps: Cell, + started: Cell, + frame: Cell, + elapsed: Cell, + last: Cell, +} + +impl RenderLoop { + pub fn new() -> Self { + Self { + playing: Cell::new(true), + fps: Cell::new(60), + started: Cell::new(false), + frame: Cell::new(0), + elapsed: Cell::new(0.0), + last: Cell::new(js_sys::Date::now()), + } + } + + pub fn play(&self) { + self.last.set(js_sys::Date::now()); + self.playing.set(true); + } + + pub fn pause(&self) { + self.playing.set(false); + } + + pub fn set_fps(&self, fps: u32) -> Result<(), &'static str> { + if !(1..=1000).contains(&fps) { + return Err("FPS"); + } + self.fps.set(fps); + Ok(()) + } + + pub fn start( + self: &Rc, + gpu: Rc>>, + store: Rc>, + data: Rc>, + ) { + if self.started.replace(true) { + return; + } + let control = self.clone(); + wasm_bindgen_futures::spawn_local(async move { + loop { + let started = js_sys::Date::now(); + if control.playing.get() { + let delta = (started - control.last.replace(started)) / 1000.0; + let elapsed = control.elapsed.get() + delta; + control.elapsed.set(elapsed); + let frame = control.frame.get().wrapping_add(1); + control.frame.set(frame); + let skip = { + let mut data = data.borrow_mut(); + data.update_info(delta as f32, frame, elapsed as f32, control.fps.get()); + data.skip_render() + }; + if !skip { + if let (Some(gpu), Some(loadout)) = + (gpu.borrow_mut().as_mut(), store.borrow_mut().active_mut()) + { + let _ = gpu.render(loadout, &data.borrow()); + } + } + } + let target = 1000.0 / f64::from(control.fps.get()); + let wait = (target - (js_sys::Date::now() - started)).max(0.0) as u32; + TimeoutFuture::new(if control.playing.get() { wait } else { 50 }).await; + } + }); + } +} + +impl Wgpu { + fn render(&mut self, loadout: &mut Loadout, data: &RenderData) -> Result<(), String> { + for buffer in loadout.resources.buffers.values() { + self.queue.write_buffer( + &buffer.buffer, + 0, + data.bytes(&buffer.source).ok_or("ROWS_UNKNOWN")?, + ); + } + let output = match self.surface.get_current_texture() { + Ok(output) => output, + Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => { + self.surface.configure(&self.device, &self.config); + self.surface.get_current_texture().map_err(|_| "SURFACE")? + } + Err(wgpu::SurfaceError::Timeout) => return Ok(()), + Err(_) => return Err("SURFACE".into()), + }; + let surface_view = output + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = self + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("frame"), + }); + for (pass, compiled) in loadout.graph.passes.iter().zip(&loadout.resources.passes) { + match compiled { + GpuPass::Compute { + pipeline, + bind_groups, + } => { + let mut command = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some(&pass.id), + timestamp_writes: None, + }); + command.set_pipeline(pipeline); + for (group, bind_group) in bind_groups { + command.set_bind_group(*group, bind_group, &[]); + } + command.dispatch_workgroups( + pass.dispatch[0], + pass.dispatch[1], + pass.dispatch[2], + ); + } + GpuPass::Render(bundle) => { + let colors = pass + .color + .iter() + .map(|attachment| { + Ok(Some(wgpu::RenderPassColorAttachment { + view: view( + &loadout.resources, + &surface_view, + &attachment.resource, + )?, + depth_slice: None, + resolve_target: None, + ops: color_ops(attachment)?, + })) + }) + .collect::, String>>()?; + let depth = pass + .depth + .as_ref() + .map(|attachment| { + Ok::<_, String>(wgpu::RenderPassDepthStencilAttachment { + view: view( + &loadout.resources, + &surface_view, + &attachment.resource, + )?, + depth_ops: Some(depth_ops(attachment)?), + stencil_ops: None, + }) + }) + .transpose()?; + let mut command = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some(&pass.id), + color_attachments: &colors, + depth_stencil_attachment: depth, + timestamp_writes: None, + occlusion_query_set: None, + }); + command.execute_bundles([bundle]); + } + } + } + self.queue.submit([encoder.finish()]); + output.present(); + Ok(()) + } +} + +fn view<'a>( + resources: &'a crate::gpu_resource::GpuResources, + surface: &'a wgpu::TextureView, + id: &str, +) -> Result<&'a wgpu::TextureView, String> { + if id == "canvas" { + Ok(surface) + } else { + resources + .texture_view(id) + .ok_or_else(|| "GRAPH_ATTACHMENT".into()) + } +} + +fn color_ops(attachment: &ColorAttachment) -> Result, String> { + let clear = attachment.clear.as_slice(); + Ok(wgpu::Operations { + load: match attachment.load.as_str() { + "load" => wgpu::LoadOp::Load, + "clear" => wgpu::LoadOp::Clear(wgpu::Color { + r: f64::from(clear.first().copied().unwrap_or(0.0)), + g: f64::from(clear.get(1).copied().unwrap_or(0.0)), + b: f64::from(clear.get(2).copied().unwrap_or(0.0)), + a: f64::from(clear.get(3).copied().unwrap_or(1.0)), + }), + _ => return Err("GRAPH_LOAD_OP".into()), + }, + store: store_op(&attachment.store)?, + }) +} + +fn depth_ops(attachment: &DepthAttachment) -> Result, String> { + Ok(wgpu::Operations { + load: match attachment.load.as_str() { + "load" => wgpu::LoadOp::Load, + "clear" => wgpu::LoadOp::Clear(attachment.clear), + _ => return Err("GRAPH_LOAD_OP".into()), + }, + store: store_op(&attachment.store)?, + }) +} + +fn store_op(value: &str) -> Result { + match value { + "store" => Ok(wgpu::StoreOp::Store), + "discard" => Ok(wgpu::StoreOp::Discard), + _ => Err("GRAPH_STORE_OP".into()), + } +} diff --git a/core/renderer/wgpu.rs b/core/renderer/wgpu.rs new file mode 100644 index 0000000..ca69f05 --- /dev/null +++ b/core/renderer/wgpu.rs @@ -0,0 +1,53 @@ +pub struct Wgpu { + _instance: wgpu::Instance, + pub surface: wgpu::Surface<'static>, + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub config: wgpu::SurfaceConfiguration, + pub format: wgpu::TextureFormat, + pub width: u32, + pub height: u32, +} + +impl Wgpu { + pub async fn new(canvas: web_sys::OffscreenCanvas) -> Result { + let width = canvas.width(); + let height = canvas.height(); + if width == 0 || height == 0 { + return Err("CANVAS_SIZE".into()); + } + let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor { + backends: wgpu::Backends::BROWSER_WEBGPU, + ..Default::default() + }); + let surface = instance + .create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas)) + .map_err(|_| "SURFACE")?; + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + compatible_surface: Some(&surface), + ..Default::default() + }) + .await + .map_err(|_| "WEBGPU_UNAVAILABLE")?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor::default()) + .await + .map_err(|_| "DEVICE")?; + let config = surface + .get_default_config(&adapter, width, height) + .ok_or("SURFACE")?; + let format = config.format; + surface.configure(&device, &config); + Ok(Self { + _instance: instance, + surface, + device, + queue, + config, + format, + width, + height, + }) + } +} diff --git a/core/src/lib.rs b/core/src/lib.rs deleted file mode 100644 index 7bfcbe7..0000000 --- a/core/src/lib.rs +++ /dev/null @@ -1,424 +0,0 @@ -use std::collections::{HashMap, HashSet}; - -use serde_json::{Map, Number, Value}; -use wasm_bindgen::prelude::*; - -const ALIGNMENT: u32 = 64; - -#[wasm_bindgen] -pub struct Core { - _arena: Box<[u8]>, - base: u32, - capacity: u32, - used: u32, -} - -#[wasm_bindgen] -impl Core { - #[wasm_bindgen(constructor)] - pub fn new(arena_bytes: u32) -> Result { - if arena_bytes < ALIGNMENT || arena_bytes > u32::MAX - (ALIGNMENT - 1) { - return Err(JsError::new("INIT")); - } - let mut arena = vec![0; (arena_bytes + ALIGNMENT - 1) as usize].into_boxed_slice(); - let pointer = arena.as_mut_ptr() as usize; - let base = ((pointer + ALIGNMENT as usize - 1) & !(ALIGNMENT as usize - 1)) as u32; - Ok(Self { - _arena: arena, - base, - capacity: arena_bytes, - used: 0, - }) - } - - pub fn allocate(&mut self, rows: u32, stride: u32, format: &str) -> Result { - if rows == 0 || stride < 16 || stride % 16 != 0 || !matches!(format, "f32" | "u32" | "i32") - { - return Err(JsError::new("ALLOCATION")); - } - let offset = align(self.used, ALIGNMENT).ok_or_else(|| JsError::new("ARENA_OOM"))?; - let bytes = rows - .checked_mul(stride) - .ok_or_else(|| JsError::new("ARENA_OOM"))?; - self.used = offset - .checked_add(bytes) - .filter(|end| *end <= self.capacity) - .ok_or_else(|| JsError::new("ARENA_OOM"))?; - Ok(self.base + offset) - } - - pub fn compile_graph(&self, source: &str) -> Result { - compile_graph(source).map_err(JsError::new) - } -} - -fn align(value: u32, alignment: u32) -> Option { - value - .checked_add(alignment - 1) - .map(|value| value & !(alignment - 1)) -} - -enum Expression { - Atom(Value), - List(Vec), -} - -struct Parser<'a> { - source: &'a [u8], - at: usize, -} - -impl<'a> Parser<'a> { - fn parse(source: &'a str) -> Result { - let mut parser = Parser { - source: source.as_bytes(), - at: 0, - }; - let expression = parser.expression()?; - parser.whitespace(); - if parser.at != parser.source.len() { - return Err("GRAPH_WIRE"); - } - Ok(expression) - } - - fn expression(&mut self) -> Result { - self.whitespace(); - match self.source.get(self.at) { - Some(b'(') => self.list(), - Some(b'"') => self.string(), - Some(b')') | None => Err("GRAPH_WIRE"), - Some(_) => self.atom(), - } - } - - fn list(&mut self) -> Result { - self.at += 1; - let mut values = Vec::new(); - loop { - self.whitespace(); - match self.source.get(self.at) { - Some(b')') => { - self.at += 1; - return Ok(Expression::List(values)); - } - None => return Err("GRAPH_WIRE"), - _ => values.push(self.expression()?), - } - } - } - - fn string(&mut self) -> Result { - let start = self.at; - self.at += 1; - let mut escaped = false; - while let Some(&byte) = self.source.get(self.at) { - self.at += 1; - if escaped { - escaped = false; - } else if byte == b'\\' { - escaped = true; - } else if byte == b'"' { - let value = serde_json::from_slice(&self.source[start..self.at]) - .map_err(|_| "GRAPH_WIRE")?; - return Ok(Expression::Atom(Value::String(value))); - } - } - Err("GRAPH_WIRE") - } - - fn atom(&mut self) -> Result { - let start = self.at; - while self - .source - .get(self.at) - .is_some_and(|byte| !byte.is_ascii_whitespace() && !matches!(byte, b'(' | b')')) - { - self.at += 1; - } - let token = std::str::from_utf8(&self.source[start..self.at]).map_err(|_| "GRAPH_WIRE")?; - let value = match token { - "true" => Value::Bool(true), - "false" => Value::Bool(false), - "null" => Value::Null, - _ => serde_json::from_str::(token) - .ok() - .filter(Value::is_number) - .unwrap_or_else(|| Value::String(token.into())), - }; - Ok(Expression::Atom(value)) - } - - fn whitespace(&mut self) { - while self - .source - .get(self.at) - .is_some_and(u8::is_ascii_whitespace) - { - self.at += 1; - } - } -} - -fn compile_graph(source: &str) -> Result { - let root = Parser::parse(source)?; - let Expression::List(mut root) = root else { - return Err("GRAPH_WIRE"); - }; - if root.len() != 3 - || !matches!(&root[0], Expression::Atom(Value::String(tag)) if tag == "yawn-graph") - || !matches!(&root[1], Expression::Atom(Value::Number(version)) if version.as_u64() == Some(1)) - { - return Err("GRAPH_WIRE"); - } - let mut graph = decode(root.pop().unwrap())?; - let object = graph.as_object_mut().ok_or("GRAPH_SHAPE")?; - if !object.get("id").is_some_and(Value::is_string) { - return Err("GRAPH_SHAPE"); - } - let passes = sort_passes(object.get("passes").ok_or("GRAPH_PASS")?)?; - plan_resources(object, &passes)?; - object.insert("passes".into(), Value::Array(passes)); - serde_json::to_string(&graph).map_err(|_| "GRAPH_WIRE") -} - -fn decode(expression: Expression) -> Result { - let Expression::List(mut values) = expression else { - return match expression { - Expression::Atom(value) => Ok(value), - Expression::List(_) => unreachable!(), - }; - }; - if values.is_empty() { - return Err("GRAPH_WIRE"); - } - let tag = match values.remove(0) { - Expression::Atom(Value::String(tag)) => tag, - _ => return Err("GRAPH_WIRE"), - }; - if tag == "array" { - return values.into_iter().map(decode).collect(); - } - if tag != "object" { - return Err("GRAPH_WIRE"); - } - let mut object = Map::new(); - for field in values { - let Expression::List(mut field) = field else { - return Err("GRAPH_WIRE"); - }; - if field.len() != 3 - || !matches!(&field[0], Expression::Atom(Value::String(tag)) if tag == "field") - { - return Err("GRAPH_WIRE"); - } - let value = decode(field.pop().unwrap())?; - let key = match field.pop().unwrap() { - Expression::Atom(Value::String(key)) => key, - _ => return Err("GRAPH_WIRE"), - }; - if object.insert(key, value).is_some() { - return Err("GRAPH_WIRE"); - } - } - Ok(Value::Object(object)) -} - -fn sort_passes(value: &Value) -> Result, &'static str> { - let passes = value.as_array().ok_or("GRAPH_PASS")?; - let mut ids = HashMap::new(); - for (index, pass) in passes.iter().enumerate() { - let id = pass - .as_object() - .and_then(|pass| pass.get("id")) - .and_then(Value::as_str) - .ok_or("GRAPH_PASS")?; - if ids.insert(id, index).is_some() { - return Err("GRAPH_PASS"); - } - } - let mut dependencies = Vec::with_capacity(passes.len()); - for pass in passes { - let after = pass.get("after").map_or(Ok(&[][..]), |after| { - after.as_array().map(Vec::as_slice).ok_or("GRAPH_ARRAY") - })?; - dependencies.push( - after - .iter() - .map(|dependency| { - dependency - .as_str() - .and_then(|id| ids.get(id).copied()) - .ok_or("GRAPH_DEPENDENCY") - }) - .collect::, _>>()?, - ); - } - let mut emitted = vec![false; passes.len()]; - let mut result = Vec::with_capacity(passes.len()); - while result.len() < passes.len() { - let ready = (0..passes.len()).find(|&index| { - !emitted[index] - && dependencies[index] - .iter() - .all(|dependency| emitted[*dependency]) - }); - let index = ready.ok_or("GRAPH_CYCLE")?; - emitted[index] = true; - result.push(passes[index].clone()); - } - Ok(result) -} - -fn plan_resources(graph: &mut Map, passes: &[Value]) -> Result<(), &'static str> { - let mut used = HashSet::new(); - let mut lifetimes = HashMap::new(); - for (frame, pass) in passes.iter().enumerate() { - for id in pass_resources(pass)? { - used.insert(id.to_owned()); - lifetimes - .entry(id.to_owned()) - .and_modify(|lifetime: &mut (usize, usize)| lifetime.1 = frame) - .or_insert((frame, frame)); - } - } - let Some(resources) = graph.get_mut("resources") else { - return Ok(()); - }; - let resources = resources.as_object_mut().ok_or("GRAPH_RESOURCE")?; - for kind in ["buffers", "samplers"] { - if let Some(declarations) = resources.get_mut(kind) { - let declarations = declarations.as_array_mut().ok_or("GRAPH_RESOURCE")?; - let mut ids = HashSet::new(); - declarations.retain(|declaration| { - declaration - .get("id") - .and_then(Value::as_str) - .is_some_and(|id| ids.insert(id.to_owned()) && used.contains(id)) - }); - } - } - let Some(textures) = resources.get_mut("textures") else { - return Ok(()); - }; - let textures = textures.as_array_mut().ok_or("GRAPH_RESOURCE")?; - let mut ids = HashSet::new(); - let mut planned = Vec::new(); - for mut declaration in textures.drain(..) { - let object = declaration.as_object_mut().ok_or("GRAPH_RESOURCE")?; - let id = object - .get("id") - .and_then(Value::as_str) - .ok_or("GRAPH_RESOURCE")?; - if !ids.insert(id.to_owned()) { - return Err("GRAPH_RESOURCE"); - } - let Some(&(first, last)) = lifetimes.get(id) else { - continue; - }; - planned.push((declaration, first, last)); - } - planned.sort_by_key(|value| value.1); - let mut output = Vec::new(); - let mut slots: Vec<(String, usize)> = Vec::new(); - for (mut declaration, first, last) in planned { - let object = declaration.as_object_mut().unwrap(); - let key = texture_key(object)?; - let slot = if object.get("transient") == Some(&Value::Bool(false)) { - None - } else { - slots - .iter() - .position(|value| value.0 == key && value.1 < first) - }; - let slot = match slot { - Some(slot) => { - slots[slot].1 = last; - slot - } - None => { - slots.push((key, last)); - slots.len() - 1 - } - }; - object.insert("slot".into(), Value::Number(Number::from(slot as u64))); - output.push(declaration); - } - *textures = output; - Ok(()) -} - -fn pass_resources(pass: &Value) -> Result, &'static str> { - let mut result = Vec::new(); - for field in ["bindings", "color", "vertexBuffers"] { - if let Some(values) = pass.get(field) { - for value in values.as_array().ok_or("GRAPH_ARRAY")? { - result.push( - value - .get("resource") - .and_then(Value::as_str) - .ok_or("GRAPH_RESOURCE")?, - ); - } - } - } - for field in ["depth", "indexBuffer"] { - if let Some(value) = pass.get(field) { - result.push( - value - .get("resource") - .and_then(Value::as_str) - .ok_or("GRAPH_RESOURCE")?, - ); - } - } - Ok(result) -} - -fn texture_key(texture: &Map) -> Result { - let mut size = texture.get("size").cloned().unwrap_or_else(|| { - Value::Array(vec![ - Value::String("canvas".into()), - Value::String("canvas".into()), - ]) - }); - let size = size.as_array_mut().ok_or("GRAPH_TEXTURE_SIZE")?; - if !(2..=3).contains(&size.len()) { - return Err("GRAPH_TEXTURE_SIZE"); - } - if size.len() == 2 { - size.push(Value::Number(Number::from(1))); - } - let format = texture - .get("format") - .and_then(Value::as_str) - .ok_or("GRAPH_RESOURCE")?; - let mut usage = texture.get("usage").map_or(Ok(Vec::new()), |usage| { - usage - .as_array() - .ok_or("GRAPH_TEXTURE_USAGE")? - .iter() - .map(|value| value.as_str().ok_or("GRAPH_TEXTURE_USAGE")) - .collect::, _>>() - })?; - usage.sort_unstable(); - usage.dedup(); - serde_json::to_string(&( - size, - format, - usage, - texture - .get("mipLevelCount") - .and_then(Value::as_u64) - .unwrap_or(1), - texture - .get("sampleCount") - .and_then(Value::as_u64) - .unwrap_or(1), - texture - .get("dimension") - .and_then(Value::as_str) - .unwrap_or("2d"), - )) - .map_err(|_| "GRAPH_RESOURCE") -} diff --git a/core/src/worker.js b/core/src/worker.js deleted file mode 100644 index 8f7eb03..0000000 --- a/core/src/worker.js +++ /dev/null @@ -1,221 +0,0 @@ -import initWasm, { Core as WasmCore } from "../pkg/yawn_core.js"; - -let canvas, context, device, surfaceFormat, memory, core, loadout; -const arrays = new Map(); -const align = (value, multiple) => Math.ceil(value / multiple) * multiple; -const fail = code => { throw new Error(code); }; -const list = value => value === undefined ? [] : Array.isArray(value) ? value : fail("GRAPH_ARRAY"); - -function index(items, code) { - const result = new Map(); - for (const item of list(items)) { - if (!item || typeof item.id !== "string" || result.has(item.id)) fail(code); - result.set(item.id, item); - } - return result; -} - -const bufferUsage = names => list(names).reduce((usage, name) => usage | ({ - uniform: GPUBufferUsage.UNIFORM, storage: GPUBufferUsage.STORAGE, - vertex: GPUBufferUsage.VERTEX, index: GPUBufferUsage.INDEX, - indirect: GPUBufferUsage.INDIRECT, copySrc: GPUBufferUsage.COPY_SRC, -}[name] ?? fail("GRAPH_BUFFER_USAGE")), GPUBufferUsage.COPY_DST); -const textureUsage = names => list(names).reduce((usage, name) => usage | ({ - render: GPUTextureUsage.RENDER_ATTACHMENT, sampled: GPUTextureUsage.TEXTURE_BINDING, - storage: GPUTextureUsage.STORAGE_BINDING, copySrc: GPUTextureUsage.COPY_SRC, - copyDst: GPUTextureUsage.COPY_DST, -}[name] ?? fail("GRAPH_TEXTURE_USAGE")), 0); - -async function compile(graph) { - if (!graph || typeof graph.id !== "string") fail("GRAPH_SHAPE"); - const passes = list(graph.passes); - const renderDeclarations = index(graph.pipelines?.render, "GRAPH_PIPELINE"); - const computeDeclarations = index(graph.pipelines?.compute, "GRAPH_PIPELINE"); - const resources = new Map(), owned = []; - try { - for (const declaration of list(graph.resources?.buffers)) { - const source = arrays.get(declaration.array); - if (!source) fail("GRAPH_ARRAY_UNKNOWN"); - const gpu = device.createBuffer({ size: align(source.bytes, 4), usage: bufferUsage(declaration.usage) }); - resources.set(declaration.id, { kind: "buffer", gpu, source }); - owned.push(gpu); - } - - const textureSlots = new Map(); - for (const declaration of list(graph.resources?.textures)) { - if (!Number.isInteger(declaration.slot)) fail("GRAPH_RESOURCE"); - const size = declaration.size ?? ["canvas", "canvas"]; - if (!Array.isArray(size) || size.length < 2 || size.length > 3) fail("GRAPH_TEXTURE_SIZE"); - const descriptor = { - size: [size[0] === "canvas" ? canvas.width : size[0], size[1] === "canvas" ? canvas.height : size[1], size[2] ?? 1], - format: declaration.format, - usage: textureUsage(declaration.usage), - mipLevelCount: declaration.mipLevelCount ?? 1, - sampleCount: declaration.sampleCount ?? 1, - dimension: declaration.dimension ?? "2d", - }; - let slot = textureSlots.get(declaration.slot); - if (!slot) { - const gpu = device.createTexture(descriptor); - slot = { gpu, view: gpu.createView() }; - textureSlots.set(declaration.slot, slot); - owned.push(gpu); - } - resources.set(declaration.id, { kind: "texture", gpu: slot.gpu, view: slot.view }); - } - for (const declaration of list(graph.resources?.samplers)) - resources.set(declaration.id, { - kind: "sampler", gpu: device.createSampler(declaration.descriptor), - }); - - const renderPipelines = new Map(), computePipelines = new Map(); - await Promise.all([...new Set(passes.filter(x => x.type === "render").map(x => x.pipeline))].map(async id => { - const declaration = renderDeclarations.get(id); - if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE"); - const module = device.createShaderModule({ code: declaration.code }); - renderPipelines.set(id, await device.createRenderPipelineAsync({ - layout: "auto", - vertex: { module, entryPoint: declaration.vertex?.entry ?? "vertex", buffers: declaration.vertex?.buffers ?? [] }, - fragment: { - module, - entryPoint: declaration.fragment?.entry ?? "fragment", - targets: list(declaration.fragment?.targets).map(target => ({ - ...target, format: target.format === "canvas" ? surfaceFormat : target.format, - })), - }, - primitive: declaration.primitive, - depthStencil: declaration.depthStencil, - multisample: declaration.multisample, - })); - })); - await Promise.all([...new Set(passes.filter(x => x.type === "compute").map(x => x.pipeline))].map(async id => { - const declaration = computeDeclarations.get(id); - if (typeof declaration?.code !== "string") fail("GRAPH_PIPELINE"); - const module = device.createShaderModule({ code: declaration.code }); - computePipelines.set(id, await device.createComputePipelineAsync({ - layout: "auto", compute: { module, entryPoint: declaration.entry ?? "main" }, - })); - })); - - const bindGroups = (pass, pipeline) => { - const groups = new Map(); - for (const binding of list(pass.bindings)) { - const resource = resources.get(binding.resource); - if (!resource) fail("GRAPH_RESOURCE_UNKNOWN"); - const value = resource.kind === "buffer" - ? { buffer: resource.gpu, offset: binding.offset ?? 0, ...(binding.size ? { size: binding.size } : {}) } - : resource.kind === "texture" ? resource.view : resource.gpu; - if (!groups.has(binding.group ?? 0)) groups.set(binding.group ?? 0, []); - groups.get(binding.group ?? 0).push({ binding: binding.binding, resource: value }); - } - return [...groups].map(([group, entries]) => [group, device.createBindGroup({ - layout: pipeline.getBindGroupLayout(group), entries, - })]); - }; - const compiled = passes.map(pass => { - const pipeline = (pass.type === "render" ? renderPipelines : computePipelines).get(pass.pipeline); - if (!pipeline) fail("GRAPH_PASS"); - return { pass, pipeline, bindGroups: bindGroups(pass, pipeline) }; - }); - return { id: graph.id, passes: compiled, resources, owned }; - } catch (error) { - owned.forEach(resource => resource.destroy?.()); - throw error; - } -} - -const clearColor = (value = [0, 0, 0, 1]) => Array.isArray(value) - ? { r: value[0], g: value[1], b: value[2], a: value[3] } - : value; - -function render() { - if (!loadout) return; - for (const resource of loadout.resources.values()) - if (resource.kind === "buffer") device.queue.writeBuffer( - resource.gpu, 0, new Uint8Array(memory, resource.source.offset, resource.source.bytes), - ); - const encoder = device.createCommandEncoder(); - for (const { pass, pipeline, bindGroups } of loadout.passes) { - if (pass.type === "compute") { - const command = encoder.beginComputePass(); - command.setPipeline(pipeline); - bindGroups.forEach(([group, value]) => command.setBindGroup(group, value)); - command.dispatchWorkgroups(...(pass.dispatch ?? [1, 1, 1])); - command.end(); - continue; - } - const view = id => id === "canvas" - ? context.getCurrentTexture().createView() - : loadout.resources.get(id)?.view ?? fail("GRAPH_ATTACHMENT"); - const command = encoder.beginRenderPass({ - colorAttachments: list(pass.color).map(attachment => ({ - view: view(attachment.resource), loadOp: attachment.load ?? "clear", - storeOp: attachment.store ?? "store", clearValue: clearColor(attachment.clear), - })), - ...(pass.depth ? { depthStencilAttachment: { - view: view(pass.depth.resource), depthLoadOp: pass.depth.load ?? "clear", - depthStoreOp: pass.depth.store ?? "store", depthClearValue: pass.depth.clear ?? 1, - } } : {}), - }); - command.setPipeline(pipeline); - bindGroups.forEach(([group, value]) => command.setBindGroup(group, value)); - list(pass.vertexBuffers).forEach(binding => command.setVertexBuffer( - binding.slot ?? 0, loadout.resources.get(binding.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"), binding.offset ?? 0, - )); - if (pass.indexBuffer) command.setIndexBuffer( - loadout.resources.get(pass.indexBuffer.resource)?.gpu ?? fail("GRAPH_RESOURCE_UNKNOWN"), - pass.indexBuffer.format ?? "uint32", pass.indexBuffer.offset ?? 0, - ); - const draw = pass.draw ?? {}; - if (pass.indexBuffer) command.drawIndexed(draw.indices ?? 0, draw.instances ?? 1, draw.firstIndex ?? 0, draw.baseVertex ?? 0, draw.firstInstance ?? 0); - else command.draw(draw.vertices ?? 3, draw.instances ?? 1, draw.firstVertex ?? 0, draw.firstInstance ?? 0); - command.end(); - } - device.queue.submit([encoder.finish()]); -} - -function tick() { - try { render(); } catch (error) { - postMessage({ type: "runtime-error", error: error?.message ?? "RENDER_ERROR" }); - loadout = undefined; - } - (globalThis.requestAnimationFrame ?? (callback => setTimeout(callback, 16)))(tick); -} - -addEventListener("message", async ({ data: message }) => { - try { - let result; - if (message.type === "init") { - if (!(message.canvas instanceof OffscreenCanvas) || !Number.isInteger(message.arenaBytes) || message.arenaBytes < 64) fail("INIT"); - canvas = message.canvas; - const wasm = await initWasm(); - core = new WasmCore(message.arenaBytes); - memory = wasm.memory.buffer; - if (!(memory instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); - const adapter = await navigator.gpu?.requestAdapter(); - if (!adapter) fail("WEBGPU_UNAVAILABLE"); - device = await adapter.requestDevice(); - context = canvas.getContext("webgpu"); - surfaceFormat = navigator.gpu.getPreferredCanvasFormat(); - context.configure({ device, format: surfaceFormat, alphaMode: "opaque" }); - device.lost.then(() => { postMessage({ type: "runtime-error", error: "DEVICE_LOST" }); loadout = undefined; }); - result = { buffer: memory }; - tick(); - } else if (message.type === "allocate") { - const { name, rows, stride, format } = message; - if (typeof name !== "string" || !name || arrays.has(name)) fail("ALLOCATION"); - const offset = core.allocate(rows, stride, format), bytes = rows * stride; - result = { name, rows, stride, format, offset }; - arrays.set(name, { ...result, bytes }); - } else if (message.type === "load-graph") { - const next = await compile(JSON.parse(core.compile_graph(message.serialized))); - const previous = loadout; - loadout = next; - previous?.owned.forEach(resource => resource.destroy?.()); - result = { id: next.id }; - } else fail("MESSAGE"); - postMessage({ request: message.request, result }); - } catch (error) { - postMessage({ request: message.request, error: error?.message ?? "CORE_ERROR" }); - } -}); diff --git a/core/worker.js b/core/worker.js new file mode 100644 index 0000000..50a9f13 --- /dev/null +++ b/core/worker.js @@ -0,0 +1,56 @@ +import initWasm, { Core } from "./pkg/yawn_core.js"; + +let core; + +const fail = code => { throw new Error(code); }; + +addEventListener("message", async ({ data: message }) => { + try { + let result; + if (message?.type === "init") { + if (core || !(message.canvas instanceof OffscreenCanvas)) fail("INIT"); + const wasm = await initWasm(); + core = new Core(message.arenaBytes); + await core.initialize(message.canvas); + const buffer = wasm.memory.buffer; + if (!(buffer instanceof SharedArrayBuffer)) fail("WASM_MEMORY_NOT_SHARED"); + result = { buffer, rows: JSON.parse(core.rows()) }; + } else { + if (!core) fail("UNINITIALIZED"); + switch (message?.type) { + case "create-rows": + result = JSON.parse(core.create_rows(message.name, message.rows, message.stride, message.format)); + break; + case "delete-rows": + core.delete_rows(message.name); + break; + case "allocate-object": + result = JSON.parse(core.allocate_object(message.name)); + break; + case "delete-object": + core.delete_object(message.name, message.id); + break; + case "compile-graph": + result = core.compile_graph(message.serialized); + break; + case "switch-loadout": + core.switch_loadout(message.id); + break; + case "play": + core.play(); + break; + case "pause": + core.pause(); + break; + case "set-fps": + core.set_fps(message.fps); + break; + default: + fail("MESSAGE"); + } + } + postMessage({ request: message.request, result }); + } catch (error) { + postMessage({ request: message?.request, error: error?.message ?? "CORE_ERROR" }); + } +}); diff --git a/docs/.vitepress/Playground.vue b/docs/.vitepress/Playground.vue index bfc3d65..dc91132 100644 --- a/docs/.vitepress/Playground.vue +++ b/docs/.vitepress/Playground.vue @@ -25,7 +25,7 @@ onMounted(async () => { canvas.value.height = 540; core = new YawnCore(canvas.value); await core.ready; - color = await core.allocateRows({ + color = await core.createRows({ name: "triangle.color", rows: 1, stride: 16,