diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..6cafde9f --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.wasm32-unknown-unknown] +rustflags = ["--cfg", 'getrandom_backend="wasm_js"'] diff --git a/.github/actions/setup/action.yml b/.github/actions/setup/action.yml index 9052ef27..ae33a94c 100644 --- a/.github/actions/setup/action.yml +++ b/.github/actions/setup/action.yml @@ -9,7 +9,7 @@ runs: using: 'composite' steps: - name: Install system dependencies - run: sudo apt-get update && sudo apt-get install -y g++ cmake pkg-config libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libasound2-dev libudev-dev libxkbcommon-x11-0 libwayland-dev libwayland-bin libxkbcommon-dev wayland-protocols libdecor-0-dev libegl1-mesa-dev libwayland-egl1-mesa + run: sudo apt-get update && sudo apt-get install -y g++ cmake pkg-config libx11-dev libxrandr-dev libxinerama-dev libxcursor-dev libxi-dev libasound2-dev libudev-dev libxkbcommon-x11-0 libwayland-dev libwayland-bin libxkbcommon-dev wayland-protocols libdecor-0-dev libegl1-mesa-dev libwayland-egl1-mesa libfontconfig1-dev shell: bash - name: Build and install GLFW 3.4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a8705023..a379efed 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -150,6 +150,77 @@ jobs: name: wheels-sdist path: crates/processing_pyo3/dist + ffi-linux: + name: FFI Linux (x86_64) + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: ./.github/actions/setup + - name: Build FFI shared library + run: cargo build --release -p processing_ffi --features wayland,x11,webcam + - name: Stage release files + run: | + mkdir -p staging/lib staging/include + cp target/release/libprocessing.so staging/lib/ + cp target/release/libprocessing.a staging/lib/ + cp crates/processing_ffi/include/processing.h staging/include/ + cp LICENSE.md staging/ + tar -czvf libprocessing-${{ github.ref_name }}-linux-x86_64.tar.gz -C staging . + - uses: actions/upload-artifact@v4 + with: + name: libprocessing-linux-x86_64 + path: libprocessing-${{ github.ref_name }}-linux-x86_64.tar.gz + + ffi-macos: + name: FFI macOS (aarch64) + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - name: Build FFI shared library + run: cargo build --release -p processing_ffi --features webcam + - name: Stage release files + run: | + mkdir -p staging/lib staging/include + cp target/release/libprocessing.dylib staging/lib/ + cp target/release/libprocessing.a staging/lib/ + cp crates/processing_ffi/include/processing.h staging/include/ + cp LICENSE.md staging/ + tar -czvf libprocessing-${{ github.ref_name }}-macos-aarch64.tar.gz -C staging . + - uses: actions/upload-artifact@v4 + with: + name: libprocessing-macos-aarch64 + path: libprocessing-${{ github.ref_name }}-macos-aarch64.tar.gz + + ffi-windows: + name: FFI Windows (x64) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: dtolnay/rust-toolchain@stable + - name: Build FFI shared library + run: cargo build --release -p processing_ffi --features webcam + - name: Stage release files + shell: pwsh + run: | + New-Item -ItemType Directory -Force -Path staging/lib, staging/include | Out-Null + Copy-Item target/release/processing.dll staging/lib/ + Copy-Item target/release/processing.dll.lib staging/lib/ + Copy-Item target/release/processing.lib staging/lib/ + Copy-Item crates/processing_ffi/include/processing.h staging/include/ + Copy-Item LICENSE.md staging/ + Compress-Archive -Path staging/* -DestinationPath libprocessing-${{ github.ref_name }}-windows-x64.zip + - uses: actions/upload-artifact@v4 + with: + name: libprocessing-windows-x64 + path: libprocessing-${{ github.ref_name }}-windows-x64.zip + publish: name: Publish to PyPI runs-on: ubuntu-latest @@ -166,3 +237,26 @@ jobs: path: dist - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: Create GitHub Release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + needs: [ffi-linux, ffi-macos, ffi-windows, linux, macos, windows, sdist] + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + pattern: libprocessing-* + merge-multiple: true + path: release-assets + - uses: actions/download-artifact@v4 + with: + pattern: wheels-* + merge-multiple: true + path: release-assets + - uses: softprops/action-gh-release@v2 + with: + files: release-assets/* + generate_release_notes: true diff --git a/Cargo.lock b/Cargo.lock index b227d056..c1bf6e52 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -20,9 +20,9 @@ checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" [[package]] name = "accesskit" -version = "0.24.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5351dcebb14b579ccab05f288596b2ae097005be7ee50a7c3d4ca9d0d5a66f6a" +checksum = "d3b7f7f85a7e5f68090000ed7622545829afd484d210358702ae4cb97dd0c320" dependencies = [ "uuid", ] @@ -37,14 +37,24 @@ dependencies = [ "hashbrown 0.16.1", ] +[[package]] +name = "accesskit_consumer" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +dependencies = [ + "accesskit", + "hashbrown 0.16.1", +] + [[package]] name = "accesskit_macos" -version = "0.26.0" +version = "0.26.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "534bc3fdc89a64a1db3c46b33c198fde2b7c3c7d094e5809c8c8bf2970c18243" +checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.38.0", "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -58,7 +68,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" dependencies = [ "accesskit", - "accesskit_consumer", + "accesskit_consumer 0.35.0", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -99,9 +109,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -132,33 +142,21 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "alsa" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed7572b7ba83a31e20d1b48970ee402d2e3e0537dcfe0a3ff4d6eb7508617d43" -dependencies = [ - "alsa-sys", - "bitflags 2.11.1", - "cfg-if 1.0.4", - "libc", -] - -[[package]] -name = "alsa" -version = "0.10.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +checksum = "812947049edcd670a82cd5c73c3661d2e58468577ba8489de58e1a73c04cbd5d" dependencies = [ "alsa-sys", - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if 1.0.4", "libc", ] [[package]] name = "alsa-sys" -version = "0.3.1" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "db8fee663d06c4e303404ef5f40488a53e062f89ba8bfed81f42325aafad1527" +checksum = "ad7569085a265dd3f607ebecce7458eaab2132a84393534c95b18dcbc3f31e04" dependencies = [ "libc", "pkg-config", @@ -171,7 +169,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f2a1bb052857d5dd49572219344a7332b31b76405648eabac5bc68978251bcd" dependencies = [ "android-properties", - "bitflags 2.11.1", + "bitflags 2.13.1", "cc", "jni 0.22.4", "libc", @@ -181,7 +179,16 @@ dependencies = [ "ndk-sys", "num_enum", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", +] + +[[package]] +name = "android-build" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9fc9904ad2ad097c3c1cfe2eacaaf0fc24710936fa9ed941cb310b7c6ed2ab7" +dependencies = [ + "windows-sys 0.52.0", ] [[package]] @@ -198,18 +205,18 @@ checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] [[package]] name = "annotate-snippets" -version = "0.12.15" +version = "0.12.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92570a3f9c98e7e84df84b71d0965ac99b1871fcd75a3773a3bd1bad13f64cf7" +checksum = "f211a51805bc641f3ad5b7664c77d2547af685cc33b4cd8d31964027a46f13f1" dependencies = [ "anstyle", "memchr", @@ -268,9 +275,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "approx" @@ -295,7 +302,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -306,9 +313,9 @@ checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-raw-xcb-connection" @@ -340,7 +347,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -351,7 +358,7 @@ checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -460,11 +467,48 @@ dependencies = [ "portable-atomic-util", ] +[[package]] +name = "audio-core" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93ebbf82d06013f4c41fe71303feb980cddd78496d904d06be627972de51a24" + +[[package]] +name = "audioadapter" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75c3943c6c7279bb25a449a8d1727480730ab2efd7b6fd5d6ca51927096e6e4" +dependencies = [ + "audio-core", + "num-traits", +] + +[[package]] +name = "audioadapter-buffers" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ece3390b6eb40379094843a1da5aaccc34bc0d85a8cbf68d09fe092fee6de29e" +dependencies = [ + "audioadapter", + "audioadapter-sample", + "num-traits", +] + +[[package]] +name = "audioadapter-sample" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1592f90413568e259413c21a41a3d571feb1774255c209e7966d98f9db708c90" +dependencies = [ + "audio-core", + "num-traits", +] + [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av-scenechange" @@ -481,7 +525,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.18", + "thiserror 2.0.20", "v_frame", "y4m", ] @@ -502,9 +546,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] @@ -517,16 +561,16 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bevy" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_internal", ] [[package]] name = "bevy_a11y" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "accesskit", "bevy_app", @@ -537,16 +581,16 @@ dependencies = [ [[package]] name = "bevy_android" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "android-activity", ] [[package]] name = "bevy_animation" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_animation_macros", "bevy_app", @@ -569,7 +613,7 @@ dependencies = [ "ron", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "thread_local", "tracing", "uuid", @@ -577,18 +621,18 @@ dependencies = [ [[package]] name = "bevy_animation_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_anti_alias" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -608,8 +652,8 @@ dependencies = [ [[package]] name = "bevy_app" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_derive", "bevy_ecs", @@ -621,7 +665,7 @@ dependencies = [ "ctrlc", "downcast-rs 2.0.2", "log", - "thiserror 2.0.18", + "thiserror 2.0.20", "variadics_please", "wasm-bindgen", "web-sys", @@ -629,8 +673,8 @@ dependencies = [ [[package]] name = "bevy_asset" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "async-broadcast", "async-channel", @@ -647,7 +691,7 @@ dependencies = [ "bevy_reflect", "bevy_tasks", "bevy_utils", - "bitflags 2.11.1", + "bitflags 2.13.1", "blake3", "crossbeam-channel", "derive_more", @@ -662,7 +706,7 @@ dependencies = [ "ron", "serde", "stackfuture", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", "wasm-bindgen", @@ -672,19 +716,19 @@ dependencies = [ [[package]] name = "bevy_asset_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_audio" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -698,8 +742,8 @@ dependencies = [ [[package]] name = "bevy_camera" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -717,14 +761,14 @@ dependencies = [ "downcast-rs 2.0.2", "serde", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "wgpu-types", ] [[package]] name = "bevy_camera_controller" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_camera", @@ -740,8 +784,8 @@ dependencies = [ [[package]] name = "bevy_clipboard" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -754,8 +798,8 @@ dependencies = [ [[package]] name = "bevy_color" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_math", "bevy_reflect", @@ -763,14 +807,14 @@ dependencies = [ "derive_more", "encase", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "wgpu-types", ] [[package]] name = "bevy_core_pipeline" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -790,7 +834,7 @@ dependencies = [ "bevy_transform", "bevy_utils", "bevy_window", - "bitflags 2.11.1", + "bitflags 2.13.1", "indexmap", "nonmax", ] @@ -803,7 +847,7 @@ dependencies = [ "ash", "bevy", "cudarc", - "thiserror 2.0.18", + "thiserror 2.0.20", "wgpu", "wgpu-hal", "windows 0.58.0", @@ -811,18 +855,18 @@ dependencies = [ [[package]] name = "bevy_derive" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_dev_tools" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -852,8 +896,8 @@ dependencies = [ [[package]] name = "bevy_diagnostic" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "atomic-waker", "bevy_app", @@ -869,8 +913,8 @@ dependencies = [ [[package]] name = "bevy_ecs" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "arrayvec", "bevy_ecs_macros", @@ -879,7 +923,7 @@ dependencies = [ "bevy_reflect", "bevy_tasks", "bevy_utils", - "bitflags 2.11.1", + "bitflags 2.13.1", "bumpalo", "concurrent-queue", "derive_more", @@ -890,34 +934,46 @@ dependencies = [ "serde", "slotmap", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "variadics_please", ] +[[package]] +name = "bevy_ecs_macro_logic" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +dependencies = [ + "bevy_macro_utils 0.19.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bevy_ecs_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_ecs_macro_logic", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_encase_derive" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "encase_derive_impl", ] [[package]] name = "bevy_feathers" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "accesskit", "bevy_a11y", @@ -947,8 +1003,8 @@ dependencies = [ [[package]] name = "bevy_gilrs" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -956,14 +1012,14 @@ dependencies = [ "bevy_platform", "bevy_time", "gilrs", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "bevy_gizmos" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -984,18 +1040,18 @@ dependencies = [ [[package]] name = "bevy_gizmos_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_gizmos_render" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1022,8 +1078,8 @@ dependencies = [ [[package]] name = "bevy_gltf" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "async-lock", "base64", @@ -1050,15 +1106,15 @@ dependencies = [ "serde", "serde_json", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wgpu-types", ] [[package]] name = "bevy_image" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1068,7 +1124,7 @@ dependencies = [ "bevy_platform", "bevy_reflect", "bevy_utils", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "futures-lite", "guillotiere", @@ -1078,15 +1134,15 @@ dependencies = [ "rectangle-pack", "ruzstd", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wgpu-types", ] [[package]] name = "bevy_input" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -1096,13 +1152,13 @@ dependencies = [ "derive_more", "log", "smol_str", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "bevy_input_focus" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -1112,13 +1168,13 @@ dependencies = [ "bevy_reflect", "bevy_window", "log", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "bevy_internal" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_a11y", "bevy_android", @@ -1176,8 +1232,8 @@ dependencies = [ [[package]] name = "bevy_light" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1186,6 +1242,7 @@ dependencies = [ "bevy_ecs", "bevy_gizmos", "bevy_image", + "bevy_log", "bevy_math", "bevy_mesh", "bevy_platform", @@ -1200,8 +1257,8 @@ dependencies = [ [[package]] name = "bevy_log" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "android_log-sys", "bevy_app", @@ -1217,19 +1274,31 @@ dependencies = [ [[package]] name = "bevy_macro_utils" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.25.13+spec-1.1.0", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.19.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7164fe229422295bba15f1885048f34f4660db069aacb895208f996b0c7784fb" dependencies = [ "proc-macro2", "quote", - "syn", - "toml_edit", + "syn 2.0.119", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "bevy_material" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_asset", "bevy_derive", @@ -1242,7 +1311,7 @@ dependencies = [ "bevy_utils", "encase", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "variadics_please", "wgpu-types", @@ -1250,37 +1319,37 @@ dependencies = [ [[package]] name = "bevy_material_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_math" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "approx", "arrayvec", "bevy_reflect", "derive_more", - "glam", + "glam 0.32.1", "itertools", "libm", - "rand 0.10.1", + "rand 0.10.2", "rand_distr", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "variadics_please", ] [[package]] name = "bevy_mesh" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1292,34 +1361,18 @@ dependencies = [ "bevy_platform", "bevy_reflect", "bevy_transform", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "derive_more", "encase", - "glam", + "glam 0.32.1", "half", "hexasphere", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wgpu-types", ] -[[package]] -name = "bevy_midi" -version = "0.13.0" -source = "git+https://github.com/BlackPhlox/bevy_midi?branch=latest#79963a94198b158de0ca0913942bb2d088f900b2" -dependencies = [ - "bevy", - "crossbeam-channel", - "getrandom 0.3.4", - "js-sys", - "midir", - "send_wrapper", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "bevy_mikktspace" version = "1.0.0" @@ -1328,8 +1381,8 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" -version = "0.1.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#60010545e20027c7ae2ca084b21ce014664ccd36" +version = "0.2.0" +source = "git+https://github.com/tychedelia/bevy_naga_reflect#3e59d3698281fcf6c56a6478be3ee12049737218" dependencies = [ "bevy", "naga", @@ -1337,8 +1390,8 @@ dependencies = [ [[package]] name = "bevy_pbr" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "arrayvec", "bevy_app", @@ -1363,7 +1416,7 @@ dependencies = [ "bevy_tasks", "bevy_transform", "bevy_utils", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "derive_more", "fixedbitset", @@ -1372,15 +1425,15 @@ dependencies = [ "offset-allocator", "smallvec", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wgpu-types", ] [[package]] name = "bevy_picking" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1402,8 +1455,8 @@ dependencies = [ [[package]] name = "bevy_platform" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "critical-section", "foldhash 0.2.0", @@ -1414,17 +1467,17 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde", - "spin 0.10.0", + "spin 0.10.1", "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "bevy_post_process" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1441,19 +1494,19 @@ dependencies = [ "bevy_shader", "bevy_utils", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "bevy_ptr" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" [[package]] name = "bevy_reflect" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "assert_type_match", "bevy_platform", @@ -1465,14 +1518,14 @@ dependencies = [ "downcast-rs 2.0.2", "erased-serde", "foldhash 0.2.0", - "glam", + "glam 0.32.1", "indexmap", "inventory", "petgraph 0.8.3", "serde", "smallvec", "smol_str", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", "variadics_please", "wgpu-types", @@ -1480,21 +1533,21 @@ dependencies = [ [[package]] name = "bevy_reflect_derive" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "indexmap", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "uuid", ] [[package]] name = "bevy_render" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "async-channel", "bevy_app", @@ -1520,12 +1573,12 @@ dependencies = [ "bevy_transform", "bevy_utils", "bevy_window", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "derive_more", "downcast-rs 2.0.2", "encase", - "glam", + "glam 0.32.1", "image", "indexmap", "itertools", @@ -1535,7 +1588,7 @@ dependencies = [ "offset-allocator", "send_wrapper", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "variadics_please", "wasm-bindgen", "weak-table", @@ -1546,19 +1599,19 @@ dependencies = [ [[package]] name = "bevy_render_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_scene" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1569,26 +1622,65 @@ dependencies = [ "bevy_reflect", "bevy_scene_macros", "bevy_utils", - "thiserror 2.0.18", + "smallvec", + "thiserror 2.0.20", "tracing", "variadics_please", ] [[package]] name = "bevy_scene_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +dependencies = [ + "bevy_ecs_macro_logic", + "bevy_macro_utils 0.19.0", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "bevy_seedling" +version = "0.8.0" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" +dependencies = [ + "bevy_app", + "bevy_asset", + "bevy_diagnostic", + "bevy_ecs", + "bevy_log", + "bevy_math", + "bevy_platform", + "bevy_reflect", + "bevy_seedling_macros", + "bevy_time", + "bevy_transform", + "bevy_utils", + "cpal 0.18.1", + "firewheel", + "firewheel-ircam-hrtf", + "rand 0.10.2", + "smallvec", + "symphonia", + "symphonium", +] + +[[package]] +name = "bevy_seedling_macros" +version = "0.8.0" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.1", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_shader" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_asset", "bevy_platform", @@ -1597,7 +1689,7 @@ dependencies = [ "naga", "naga_oil", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wesl", "wgpu-naga-bridge", @@ -1606,8 +1698,8 @@ dependencies = [ [[package]] name = "bevy_sprite" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1631,8 +1723,8 @@ dependencies = [ [[package]] name = "bevy_sprite_render" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1653,7 +1745,7 @@ dependencies = [ "bevy_text", "bevy_transform", "bevy_utils", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "derive_more", "fixedbitset", @@ -1663,8 +1755,8 @@ dependencies = [ [[package]] name = "bevy_state" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -1678,18 +1770,18 @@ dependencies = [ [[package]] name = "bevy_state_macros" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ - "bevy_macro_utils", + "bevy_macro_utils 0.19.0", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "bevy_tasks" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "async-channel", "async-executor", @@ -1706,8 +1798,8 @@ dependencies = [ [[package]] name = "bevy_text" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1721,21 +1813,21 @@ dependencies = [ "bevy_platform", "bevy_reflect", "bevy_utils", - "parley", + "parley 0.9.0", "serde", "smallvec", "smol_str", "swash", "sys-locale", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "wgpu-types", ] [[package]] name = "bevy_time" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -1748,8 +1840,8 @@ dependencies = [ [[package]] name = "bevy_transform" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_ecs", @@ -1759,13 +1851,13 @@ dependencies = [ "bevy_utils", "derive_more", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "bevy_ui" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "accesskit", "bevy_a11y", @@ -1790,19 +1882,19 @@ dependencies = [ "bevy_utils", "bevy_window", "derive_more", - "parley", + "parley 0.9.0", "smallvec", "swash", "taffy", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "uuid", ] [[package]] name = "bevy_ui_render" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1833,8 +1925,8 @@ dependencies = [ [[package]] name = "bevy_ui_widgets" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "accesskit", "bevy_a11y", @@ -1850,14 +1942,14 @@ dependencies = [ "bevy_text", "bevy_ui", "bevy_window", - "parley", + "parley 0.9.0", "smol_str", ] [[package]] name = "bevy_utils" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "async-channel", "bevy_platform", @@ -1868,8 +1960,8 @@ dependencies = [ [[package]] name = "bevy_window" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1886,8 +1978,8 @@ dependencies = [ [[package]] name = "bevy_winit" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "accesskit", "accesskit_winit", @@ -1918,8 +2010,8 @@ dependencies = [ [[package]] name = "bevy_world_serialization" -version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +version = "0.19.0" +source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" dependencies = [ "bevy_app", "bevy_asset", @@ -1933,7 +2025,7 @@ dependencies = [ "derive_more", "ron", "serde", - "thiserror 2.0.18", + "thiserror 2.0.20", "uuid", ] @@ -1955,8 +2047,8 @@ dependencies = [ "quote", "regex", "rustc-hash", - "shlex", - "syn", + "shlex 1.3.0", + "syn 2.0.119", "which", ] @@ -2004,9 +2096,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "bytemuck", "serde_core", @@ -2023,9 +2115,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "76ae7bad254120e9e4c63bafc385310756f90c484eac0e36b8317cf09cb92a77" dependencies = [ "arrayref", "arrayvec", @@ -2083,34 +2175,34 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" dependencies = [ "bytemuck_derive", ] [[package]] name = "bytemuck_derive" -version = "1.10.2" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -2127,9 +2219,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "calloop" @@ -2137,7 +2229,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "log", "polling", "rustix 0.38.44", @@ -2159,9 +2251,9 @@ dependencies = [ [[package]] name = "cbindgen" -version = "0.29.2" +version = "0.29.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "befbfd072a8e81c02f8c507aefce431fe5e7d051f83d48a23ffc9b9fe5a11799" +checksum = "2ecb53484c9c167ba674026b656d8a27d7657a58e6066aa902bfb1a4aa00ae20" dependencies = [ "clap", "heck", @@ -2171,21 +2263,21 @@ dependencies = [ "quote", "serde", "serde_json", - "syn", + "syn 2.0.119", "tempfile", "toml", ] [[package]] name = "cc" -version = "1.2.60" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", "libc", - "shlex", + "shlex 2.0.1", ] [[package]] @@ -2217,15 +2309,15 @@ checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] name = "cfg_aliases" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if 1.0.4", "cpufeatures 0.3.0", @@ -2234,29 +2326,29 @@ dependencies = [ [[package]] name = "clang-sys" -version = "1.8.1" +version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b023947811758c97c59bf9d1c188fd619ad4718dcaa767947df1cadb14f39f4" +checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading", + "libloading 0.8.9", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", ] [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -2270,6 +2362,15 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.20.2" @@ -2291,7 +2392,7 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81411967c50ee9a1fc11365f8c585f863a22a9697c89239c452292c40ba79b0d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block", "core-foundation 0.10.1", "core-graphics-types 0.2.0", @@ -2491,7 +2592,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "core-foundation 0.10.1", "libc", ] @@ -2532,11 +2633,11 @@ dependencies = [ [[package]] name = "coreaudio-rs" -version = "0.13.0" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +checksum = "7d5d7dca3ebcf65a035582c9ad4385371a9d9ee6537474d2a278f4e1e475bb58" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", "objc2-audio-toolbox", "objc2-core-audio", @@ -2546,11 +2647,11 @@ dependencies = [ [[package]] name = "coremidi" -version = "0.9.0" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f32f5d3e1b800aa7ea10e9e83c87cbc1daef1397ee86cd599458b3c3b136428a" +checksum = "a57ede822fdaf19280cf1320a5a5d3a522c75c910d01750af1e8122b6ad2595b" dependencies = [ - "block", + "block2 0.6.2", "core-foundation 0.10.1", "core-foundation-sys 0.8.7", "coremidi-sys", @@ -2558,26 +2659,57 @@ dependencies = [ [[package]] name = "coremidi-sys" -version = "3.2.0" +version = "3.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc9504310988d938e49fff1b5f1e56e3dafe39bb1bae580c19660b58b83a191e" +checksum = "1a2b8c2cefa9a8f712213c5a1383ffe428efc8f1a1fd1e2f757be94daf7e256a" dependencies = [ "core-foundation-sys 0.8.7", ] [[package]] name = "cpal" -version = "0.17.1" +version = "0.17.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +checksum = "d8942da362c0f0d895d7cac616263f2f9424edc5687364dfd1d25ef7eba506d7" dependencies = [ - "alsa 0.10.0", + "alsa", "coreaudio-rs", "dasp_sample", "jni 0.21.1", "js-sys", "libc", - "mach2", + "mach2 0.5.0", + "ndk", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.62.2", +] + +[[package]] +name = "cpal" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f77b11176c37874be37e8d691c946e31b2b8c357abce9526f6a99eb469e1028" +dependencies = [ + "alsa", + "block2 0.6.2", + "coreaudio-rs", + "dasp_sample", + "jni 0.22.4", + "js-sys", + "libc", + "mach2 0.6.0", "ndk", "ndk-context", "num-derive", @@ -2593,6 +2725,7 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "windows 0.62.2", + "windows-core 0.62.2", ] [[package]] @@ -2630,18 +2763,18 @@ checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -2649,27 +2782,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -2694,7 +2827,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162" dependencies = [ "dispatch2", - "nix 0.31.2", + "nix", "windows-sys 0.61.2", ] @@ -2704,7 +2837,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aa12038120eb13347a6ae2ffab1d34efe78150125108627fd85044dd4d6ff1e" dependencies = [ - "libloading", + "libloading 0.8.9", ] [[package]] @@ -2721,9 +2854,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" [[package]] name = "derive_more" @@ -2744,7 +2877,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn", + "syn 2.0.119", "unicode-xid", ] @@ -2770,7 +2903,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -2778,13 +2911,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -2799,7 +2932,17 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading", + "libloading 0.8.9", +] + +[[package]] +name = "dlopen2" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60768c353d76ba6dd1b6332a5dbbdd3bc2dddadc2935f4cc6a9d0f17ca8ecb6a" +dependencies = [ + "libc", + "windows-sys 0.61.2", ] [[package]] @@ -2837,9 +2980,9 @@ checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" [[package]] name = "either" -version = "1.15.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "ena" @@ -2858,7 +3001,7 @@ checksum = "6e3e0ff2ee0b7aa97428308dd9e1e42369cb22f5fb8dc1c55546637443a60f1e" dependencies = [ "const_panic", "encase_derive", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -2878,7 +3021,7 @@ checksum = "c8bad72d8308f7a382de2391ec978ddd736e0103846b965d7e2a63a75768af30" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2898,7 +3041,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -2939,11 +3082,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -2960,44 +3102,38 @@ dependencies = [ [[package]] name = "exr" -version = "1.74.0" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", "lebe", "miniz_oxide", + "num-complex", + "pulp", "rayon-core", "smallvec", "zune-inflate", ] [[package]] -name = "fastrand" -version = "2.4.1" +name = "extended" +version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" [[package]] -name = "fax" -version = "0.2.6" +name = "fastrand" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] -name = "fax_derive" -version = "0.2.0" +name = "fax" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -3019,52 +3155,212 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] -name = "fixedbitset" -version = "0.5.7" +name = "firewheel" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" +checksum = "cce6a0e2684404563f22daf00caa3672a9fd1503af5c58cd79a544a8221cec6e" +dependencies = [ + "firewheel-core", + "firewheel-cpal", + "firewheel-graph", + "firewheel-nodes", + "firewheel-rtaudio", + "firewheel-symphonium", + "thiserror 2.0.20", +] [[package]] -name = "flate2" -version = "1.1.9" +name = "firewheel-core" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "4c793ba83a0c4861d467af68844970bd063249189790f00c7feda5019732466b" dependencies = [ - "crc32fast", - "miniz_oxide", + "arrayvec", + "audioadapter", + "audioadapter-buffers", + "audioadapter-sample", + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "bitflags 2.13.1", + "firewheel-macros", + "glam 0.29.3", + "glam 0.30.10", + "glam 0.31.1", + "glam 0.32.1", + "num-traits", + "portable-atomic", + "ringbuf", + "smallvec", + "thiserror 2.0.20", + "thunderdome", + "wmidi", ] [[package]] -name = "float_next_after" -version = "1.0.0" +name = "firewheel-cpal" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" +checksum = "6cf893a68d8b89fbb6eeca6deb5166fd342cc827bf4ad37d31a973bc861bf038" +dependencies = [ + "audioadapter-buffers", + "bevy_platform", + "cpal 0.18.1", + "firewheel-core", + "firewheel-graph", + "fixed-resample", + "thiserror 2.0.20", + "tracing", +] [[package]] -name = "flume" -version = "0.11.1" +name = "firewheel-graph" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +checksum = "00257afdba7edc48ce1667fc144d453f7bd17674a78450207507ca286e818b82" dependencies = [ - "futures-core", - "futures-sink", - "nanorand", - "spin 0.9.8", + "arrayvec", + "audioadapter", + "bevy_platform", + "bevy_reflect", + "bitflags 2.13.1", + "firewheel-core", + "num-traits", + "ringbuf", + "smallvec", + "thiserror 2.0.20", + "thunderdome", + "tracing", + "triple_buffer", + "wasm-bindgen", ] [[package]] -name = "fnv" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +name = "firewheel-ircam-hrtf" +version = "0.5.0" +source = "git+https://github.com/CorvusPrudens/bevy_seedling?branch=master#ab4a88b430d216e8614549926e3532bcec5ce2db" +dependencies = [ + "bevy_ecs", + "bevy_reflect", + "firewheel", + "glam 0.32.1", + "hrtf", +] [[package]] -name = "foldhash" +name = "firewheel-macros" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e19ca164d0116746e81d86266ad78d0d57e1be18437439ca6c4c8a97160bec2f" +dependencies = [ + "bevy_macro_utils 0.19.1", + "proc-macro2", + "quote", + "syn 2.0.119", + "toml_edit 0.23.10+spec-1.0.0", +] + +[[package]] +name = "firewheel-nodes" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32f4d58cfbb1c65d19e4490ed81b2e16153ab5e355535ef79aef044583adfcba" +dependencies = [ + "bevy_ecs", + "bevy_platform", + "bevy_reflect", + "firewheel-core", + "num-traits", + "smallvec", + "thiserror 2.0.20", + "triple_buffer", +] + +[[package]] +name = "firewheel-rtaudio" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a136e3893d1c1b691f355ba7b535e0a7df16ad49b7788aa88805bf473105a755" +dependencies = [ + "audioadapter-buffers", + "bevy_platform", + "firewheel-core", + "firewheel-graph", + "rtaudio", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "firewheel-symphonium" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8261282ec848bcd8e64c715fb6d3778e2208819b1c5c015f04bc8709ac5465f" +dependencies = [ + "bevy_platform", + "firewheel-core", + "symphonium", +] + +[[package]] +name = "fixed-resample" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3505607cc0adc388ce98f59707477c02fbd05a2fa0c1b8c4cf42b71a3cb9c34" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "ringbuf", + "rubato 4.0.0", +] + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "float_next_after" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8bf7cc16383c4b8d58b9905a8509f02926ce3058053c056376248d958c9df1e8" + +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "nanorand", + "spin 0.9.9", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" @@ -3075,6 +3371,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "font-types" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39a654f404bbcbd48ea58c617c2993ee91d1cb63727a37bf2323a4edeed1b8c5" +dependencies = [ + "bytemuck", +] + [[package]] name = "font-types" version = "0.11.3" @@ -3084,13 +3389,45 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "font-types" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75382bc7392ef10aad10935f92fc3db36d2d4dad0e5d96d8d65e04f89a07ec39" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "fontique" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bbc252c93499b6d3635d692f892a637db0dbb130ce9b32bf20b28e0dcc470b" +dependencies = [ + "bytemuck", + "hashbrown 0.16.1", + "icu_locale_core", + "linebender_resource_handle", + "memmap2", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-text", + "objc2-foundation 0.3.2", + "read-fonts 0.35.0", + "roxmltree", + "smallvec", + "windows 0.58.0", + "windows-core 0.58.0", + "yeslogic-fontconfig-sys", +] + [[package]] name = "fontique" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c20b425addb8661e97fe1d51c4d8bcec3ec29ed6ad0db983976a7521276b8f7" dependencies = [ - "hashbrown 0.17.0", + "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", "parlance", @@ -3119,13 +3456,13 @@ dependencies = [ [[package]] name = "foreign-types-macros" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +checksum = "ea5190182e6915eb873ddbc16e23b711b6eb1f9c00a0d0a3a91b5f6228475225" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -3151,24 +3488,24 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", ] [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-lite" @@ -3185,32 +3522,32 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-core", "futures-macro", @@ -3221,7 +3558,7 @@ dependencies = [ [[package]] name = "generate_stubs" -version = "0.1.0" +version = "0.0.5" dependencies = [ "pyo3-introspection", ] @@ -3275,16 +3612,16 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if 1.0.4", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -3299,9 +3636,9 @@ dependencies = [ [[package]] name = "gilrs" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fa85c2e35dc565c90511917897ea4eae16b77f2773d5223536f7b602536d462" +checksum = "902fb00d3f6398e635be22e5c837b303c501835cca7ac11a47bba138f7aafdd8" dependencies = [ "fnv", "gilrs-core", @@ -3312,16 +3649,16 @@ dependencies = [ [[package]] name = "gilrs-core" -version = "0.6.7" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d23f2cc5144060a7f8d9e02d3fce5d06705376568256a509cdbc3c24d47e4f04" +checksum = "dc7f0ce6237abcc0523f2a5502b1e3fe5802daaae47ac14e166fe49551301ea9" dependencies = [ "inotify", "js-sys", "libc", "libudev-sys", "log", - "nix 0.30.1", + "nix", "objc2-core-foundation", "objc2-io-kit", "uuid", @@ -3342,6 +3679,24 @@ dependencies = [ "xml-rs", ] +[[package]] +name = "glam" +version = "0.29.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" + +[[package]] +name = "glam" +version = "0.30.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19fc433e8437a212d1b6f1e68c7824af3aed907da60afa994e7f542d18d12aa9" + +[[package]] +name = "glam" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556f6b2ea90b8d15a74e0e7bb41671c9bdf38cd9f78c284d750b9ce58a2b5be7" + [[package]] name = "glam" version = "0.32.1" @@ -3351,7 +3706,7 @@ dependencies = [ "bytemuck", "encase", "libm", - "rand 0.10.1", + "rand 0.10.2", "serde_core", ] @@ -3381,9 +3736,9 @@ dependencies = [ [[package]] name = "glob" -version = "0.3.3" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" +checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" [[package]] name = "glow" @@ -3418,7 +3773,7 @@ dependencies = [ "inflections", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3444,9 +3799,9 @@ dependencies = [ [[package]] name = "goblin" -version = "0.10.5" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "983a6aafb3b12d4c41ea78d39e189af4298ce747353945ff5105b54a056e5cd9" +checksum = "17582616a7718cca54cec18e534a76c7c4aec11a8b9a85695712f262fd15a4c8" dependencies = [ "log", "plain", @@ -3463,7 +3818,7 @@ dependencies = [ "hashbrown 0.16.1", "log", "presser", - "thiserror 2.0.18", + "thiserror 2.0.20", "windows 0.62.2", ] @@ -3473,7 +3828,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -3484,7 +3839,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -3519,11 +3874,24 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.6.0" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92c020db12c71d8a12a3fe7607873cade3a01a6287e29d540c8723276221b9d8" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "core_maths", + "read-fonts 0.35.0", + "smallvec", +] + +[[package]] +name = "harfrust" +version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9ce5e848d21ba97a324266e41c70e0eb5e2577a610c1fbd546e15096f2e8e37" +checksum = "551ed25397e4b444e89686602877d5cf3a7f6e3d548dcac37a8357d1e195f4df" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "core_maths", "read-fonts 0.39.2", @@ -3563,18 +3931,18 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ "foldhash 0.2.0", ] [[package]] name = "heapless" -version = "0.9.2" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" dependencies = [ "hash32", "portable-atomic", @@ -3600,7 +3968,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "177ea6330876de4ef08e184f827b98acc037614a4ce409902c5b0d53a9c2e951" dependencies = [ "constgebra", - "glam", + "glam 0.32.1", "tinyvec", ] @@ -3619,11 +3987,22 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "hrtf" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f4de47a84fd55fa33aa5ef337016814fdc869fdad23e7898b5322fa290248e6" +dependencies = [ + "byteorder", + "rubato 0.14.1", + "rustfft", +] + [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -3634,45 +4013,44 @@ dependencies = [ ] [[package]] -name = "icu_locale" -version = "2.2.0" +name = "icu_locale_core" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5a396343c7208121dc86e35623d3dfe19814a7613cfd14964994cdc9c9a2e26" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ - "icu_collections", - "icu_locale_core", - "icu_locale_data", - "icu_provider", - "potential_utf", + "displaydoc", + "litemap", + "serde", "tinystr", + "writeable", "zerovec", ] [[package]] -name = "icu_locale_core" -version = "2.2.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" dependencies = [ - "displaydoc", - "litemap", - "serde", + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", "tinystr", - "writeable", "zerovec", ] [[package]] -name = "icu_locale_data" -version = "2.2.0" +name = "icu_locale_fallback_data" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fdcc9ac77c6d74ff5cf6e65ef3181d6af32003b16fce3a77fb451d2f695993" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -3684,16 +4062,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -3704,15 +4083,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -3727,30 +4106,25 @@ dependencies = [ [[package]] name = "icu_segmenter" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c0794db0b1a86193ac9c48768d0e6c52c54448e0870ad87907d456ee0dac964" +checksum = "82d07aafccd67af15d02512a6adf5896fbc5ed00f2e99b471d2efa14016db3db" dependencies = [ "icu_collections", - "icu_locale", + "icu_locale_fallback", "icu_provider", "icu_segmenter_data", "potential_utf", + "smallvec", "utf8_iter", "zerovec", ] [[package]] name = "icu_segmenter_data" -version = "2.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e4a2c462a4d927d512f5f882a033ddd62f33a05bb9f230d98f736ac3dc85938f" - -[[package]] -name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" [[package]] name = "image" @@ -3788,9 +4162,9 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" [[package]] name = "indexmap" @@ -3799,7 +4173,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -3812,20 +4186,20 @@ checksum = "a257582fdcde896fd96463bf2d40eefea0580021c0712a0e2b028b60b47a837a" [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "153be1941a183ec9ccd095ddbe17a8b8d435ef6c76e9e02451b933c3999af2c8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -3838,7 +4212,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -3871,6 +4245,15 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "java-locator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f" +dependencies = [ + "glob", +] + [[package]] name = "jni" version = "0.21.1" @@ -3880,7 +4263,9 @@ dependencies = [ "cesu8", "cfg-if 1.0.4", "combine", + "java-locator", "jni-sys 0.3.1", + "libloading 0.7.4", "log", "thiserror 1.0.69", "walkdir", @@ -3899,7 +4284,7 @@ dependencies = [ "jni-sys 0.4.1", "log", "simd_cesu8", - "thiserror 2.0.18", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -3914,7 +4299,22 @@ dependencies = [ "quote", "rustc_version", "simd_cesu8", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "jni-min-helper" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913370a00ba1851b0f3369b49d0f3e745da2d20d3f15ecc296a64234ccda45f7" +dependencies = [ + "android-build", + "dlopen2", + "jni 0.21.1", + "libc", + "log", + "ndk-context", + "process_path", ] [[package]] @@ -3942,28 +4342,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" dependencies = [ "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if 1.0.4", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -3983,7 +4382,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading", + "libloading 0.8.9", "pkg-config", ] @@ -3995,9 +4394,9 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -4005,21 +4404,21 @@ dependencies = [ [[package]] name = "kqueue-sys" -version = "1.0.4" +version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed9625ffda8729b85e45cf04090035ac368927b8cebc34898e7c120f52e4838b" +checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 1.3.2", + "bitflags 2.13.1", "libc", ] [[package]] name = "ktx2" -version = "0.4.0" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff7f53bdf698e7aa7ec916411bbdc8078135da11b66db5182675b2227f6c0d07" +checksum = "1d50be8a24c37debdf3170be6ca396d5b5b165f37f94837891adac735ca416b8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] @@ -4065,12 +4464,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "830d08ce1d1d941e6b30645f1a0eb5643013d835ce3779a5fc208261dbe10f55" -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lebe" version = "0.5.3" @@ -4156,20 +4549,30 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", ] +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if 1.0.4", + "winapi", +] + [[package]] name = "libloading" version = "0.8.9" @@ -4188,14 +4591,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.9.2", ] [[package]] @@ -4228,9 +4631,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "litrs" @@ -4249,9 +4652,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] name = "logos" @@ -4273,7 +4676,7 @@ dependencies = [ "quote", "regex-automata", "regex-syntax", - "syn", + "syn 2.0.119", ] [[package]] @@ -4306,9 +4709,9 @@ dependencies = [ [[package]] name = "lyon_algorithms" -version = "1.0.19" +version = "1.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9815fac08e6fd96733a11dce4f9d15a3f338e96a2e2311ee21e1b738efc2bc0f" +checksum = "8575c0d003ae459399623c4def180c63b77f343b1a7fee64f249b349e7699a31" dependencies = [ "lyon_path", "num-traits", @@ -4356,9 +4759,15 @@ dependencies = [ ] [[package]] -name = "malloc_buf" -version = "0.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" +name = "mach2" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae608c151f68243f2b000364e1f7b186d9c29845f7d2d85bd31b9ad77ad552b" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" dependencies = [ "libc", @@ -4385,15 +4794,15 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -4415,19 +4824,23 @@ dependencies = [ [[package]] name = "midir" -version = "0.10.4" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56542e359bb7e4bd1a77cb79042be32d4af0713a9ce58160355eaf72df9db87c" +checksum = "77c12e74a8604bc07f9a3fcf3d5889a81bc96ca6e07d6a114d63fc0371f3e5a4" dependencies = [ - "alsa 0.9.1", - "bitflags 1.3.2", + "alsa", + "cc", "coremidi", + "jni 0.21.1", + "jni-min-helper", "js-sys", "libc", + "log", + "ndk-context", "parking_lot", "wasm-bindgen", "web-sys", - "windows 0.56.0", + "windows 0.62.2", ] [[package]] @@ -4448,9 +4861,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" dependencies = [ "libc", "log", @@ -4495,13 +4908,13 @@ dependencies = [ [[package]] name = "naga" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa2630921705b9b01dcdd0b6864b9562ca3c1951eecd0f0c4f5f04f61e412647" +checksum = "b2bf919621e7975acb27d881bae2fb993e0d45c8e0446e85e6272971e00dc8df" dependencies = [ "arrayvec", "bit-set 0.9.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if 1.0.4", "cfg_aliases", "codespan-reporting 0.13.1", @@ -4517,7 +4930,7 @@ dependencies = [ "rustc-hash", "serde", "spirv", - "thiserror 2.0.18", + "thiserror 2.0.20", "unicode-ident", ] @@ -4533,15 +4946,25 @@ dependencies = [ "naga", "regex", "rustc-hash", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", "unicode-ident", ] +[[package]] +name = "nannou_midi" +version = "0.1.0" +source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f713c439c03b4e4614c2ef8ff899932efd0a1c44" +dependencies = [ + "bevy", + "crossbeam-channel", + "midir", +] + [[package]] name = "nannou_webcam" version = "0.1.0" -source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#da69830a7fd1b40045b14aa18b28d5d51e02ee49" +source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f713c439c03b4e4614c2ef8ff899932efd0a1c44" dependencies = [ "bevy", "console_error_panic_hook", @@ -4577,7 +5000,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "jni-sys 0.3.1", "log", "ndk-sys", @@ -4609,23 +5032,11 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "nix" -version = "0.30.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74523f3a35e05aba87a1d978330aef40f67b0304ac79c1c00b294c9830543db6" -dependencies = [ - "bitflags 2.11.1", - "cfg-if 1.0.4", - "cfg_aliases", - "libc", -] - -[[package]] -name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cfg-if 1.0.4", "cfg_aliases", "libc", @@ -4633,18 +5044,18 @@ dependencies = [ [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] [[package]] name = "nokhwa" -version = "0.10.10" +version = "0.10.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4cae50786bfa1214ed441f98addbea51ca1b9aaa9e4bf5369cda36654b3efaa" +checksum = "9d63f10b450319a0ace7aa8e0e25477d1fdb345313a97e220e886175539a1dbb" dependencies = [ "flume", "image", @@ -4653,24 +5064,25 @@ dependencies = [ "nokhwa-bindings-windows", "nokhwa-core", "paste", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] name = "nokhwa-bindings-linux" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd666aaa41d14357817bd9a981773a73c4d00b34d344cfc244e47ebd397b1ec" +checksum = "bb67e22201a53322291740ca064b20eaaade7222ef0349f312d9b37b004e1984" dependencies = [ + "libc", "nokhwa-core", "v4l", ] [[package]] name = "nokhwa-bindings-macos" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de78eb4a2d47a68f490899aa0516070d7a972f853ec2bb374ab53be0bd39b60f" +checksum = "f70d3908ea68324e44a6b3a0f885aa59e433fb1f6678839d09e0df7d226fb42d" dependencies = [ "block", "cocoa-foundation", @@ -4685,9 +5097,9 @@ dependencies = [ [[package]] name = "nokhwa-bindings-windows" -version = "0.4.5" +version = "0.4.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899799275c93ef69bbe8cb888cf6f8249abe751cbc50be5299105022aec14a1c" +checksum = "5be28886bad8abcec3655c1f24b965b4cb596a72b23164c910c54439ce55d2a4" dependencies = [ "nokhwa-core", "once_cell", @@ -4696,14 +5108,14 @@ dependencies = [ [[package]] name = "nokhwa-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "109975552bbd690894f613bce3d408222911e317197c72b2e8b9a1912dc261ae" +checksum = "b1cba20bebd3bd9ae22f9273ade5bbe49da3e047c8512b53fbaf8b4b9c80d496" dependencies = [ "bytes", "image", "mozjpeg", - "thiserror 2.0.18", + "thiserror 2.0.20", ] [[package]] @@ -4743,7 +5155,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "fsevent-sys", "inotify", "kqueue", @@ -4774,9 +5186,15 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] +[[package]] +name = "notosans" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "004d578bbfc8a6bdd4690576a8381af234ef051dd4cc358604e1784821e8205c" + [[package]] name = "ntapi" version = "0.4.3" @@ -4797,14 +5215,24 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -4813,14 +5241,14 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -4865,7 +5293,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -4909,7 +5337,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4e89ad9e3d7d297152b17d39ed92cd50ca8063a89a9fa569046d41568891eff" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "libc", "objc2 0.5.2", @@ -4925,7 +5353,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -4946,7 +5374,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "objc2 0.6.4", "objc2-core-audio", @@ -4961,6 +5389,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" dependencies = [ + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -4971,7 +5400,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -4984,7 +5413,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "73ad74d880bb43877038da939b7427bba67e9dd42004a18b809ba7d87cee241c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5019,7 +5448,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", ] @@ -5029,7 +5458,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "617fbf49e071c178c0b24c080767db52958f716d9eabdf0890523aeae54773ef" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5041,7 +5470,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-foundation 0.3.2", ] @@ -5052,7 +5481,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "dispatch2", "libc", @@ -5065,7 +5494,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dispatch2", "objc2 0.6.4", "objc2-core-foundation", @@ -5112,7 +5541,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0cde0dfb48d25d2b4862161a4d5fcc0e3c24367869ad306b0c9ec0073bfed92d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -5124,7 +5553,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", @@ -5143,7 +5572,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ee638a5da3799329310ad4cfa62fbf045d5f56e3ef5ba4149e7452dcf89d5a8" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "dispatch", "libc", @@ -5156,7 +5585,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "libc", "objc2 0.6.4", @@ -5169,7 +5598,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33fafba39597d6dc1fb709123dfa8289d39406734be322956a69f0931c73bb15" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "libc", "objc2-core-foundation", ] @@ -5180,7 +5609,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", ] @@ -5203,7 +5632,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd0cba1276f6023976a406a14ffa85e1fdd19df6b0f737b063b95f6c8c7aadd6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5215,7 +5644,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "objc2 0.6.4", "objc2-foundation 0.3.2", @@ -5227,7 +5656,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e42bee7bff906b14b167da2bac5efe6b6a07e6f7c0a21a7308d40c960242dc7a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-foundation 0.2.2", @@ -5240,7 +5669,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", "objc2-foundation 0.3.2", @@ -5263,7 +5692,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-cloud-kit 0.2.2", @@ -5295,7 +5724,7 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "objc2 0.5.2", "objc2-core-location", @@ -5344,9 +5773,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "orbclient" -version = "0.3.53" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12c6933ddbbd16539a7672e697bb8d41ac3a4e99ac43eeb40c07236bd7fcb2dd" +checksum = "5df339f526ea9a60e371768d50efc2f2508c7203290731565d1f7a6f71d21747" dependencies = [ "libc", "libredox", @@ -5405,15 +5834,29 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b6937eda350acc1a5d05872c3cbf99fe78619c269096e2be3d4a350058639d5" +[[package]] +name = "parley" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ada5338c3a9794af7342e6f765b6e78740db37378aced034d7bf72c96b94ed94" +dependencies = [ + "fontique 0.7.0", + "harfrust 0.3.2", + "hashbrown 0.16.1", + "linebender_resource_handle", + "skrifa 0.37.0", + "swash", +] + [[package]] name = "parley" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343" dependencies = [ - "fontique", - "harfrust", - "hashbrown 0.17.0", + "fontique 0.9.0", + "harfrust 0.6.2", + "hashbrown 0.17.1", "icu_normalizer", "icu_properties", "icu_segmenter", @@ -5490,22 +5933,22 @@ dependencies = [ [[package]] name = "pin-project" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1749c7ed4bcaf4c3d0a3efc28538844fb29bcdd7d2b67b2be7e20ba861ff517" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.11" +version = "1.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -5543,7 +5986,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "crc32fast", "fdeflate", "flate2", @@ -5566,9 +6009,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.13.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -5581,9 +6024,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "serde_core", "writeable", @@ -5627,7 +6070,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", ] [[package]] @@ -5636,24 +6088,35 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit", + "toml_edit 0.25.13+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] +[[package]] +name = "process_path" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f676f11eb0b3e2ea0fbaee218fa6b806689e2297b8c8adc5bf73df465c4f6171" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "processing" -version = "0.0.1" +version = "0.0.8" dependencies = [ "bevy", "js-sys", + "processing_audio", "processing_core", "processing_cuda", "processing_glfw", @@ -5661,26 +6124,34 @@ dependencies = [ "processing_midi", "processing_render", "processing_webcam", - "rand 0.10.1", + "rand 0.10.2", "tracing", "tracing-subscriber", "wasm-bindgen-futures", "web-sys", ] +[[package]] +name = "processing_audio" +version = "0.1.0" +dependencies = [ + "bevy", + "bevy_seedling", +] + [[package]] name = "processing_core" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "raw-window-handle", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "processing_cuda" -version = "0.1.0" +version = "0.0.5" dependencies = [ "bevy", "bevy_cuda", @@ -5690,27 +6161,29 @@ dependencies = [ [[package]] name = "processing_ffi" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "cbindgen", + "half", "processing", ] [[package]] name = "processing_glfw" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "glfw", "processing_core", "processing_input", "processing_render", + "x11", ] [[package]] name = "processing_input" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "processing_core", @@ -5718,59 +6191,67 @@ dependencies = [ [[package]] name = "processing_midi" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", - "bevy_midi", + "nannou_midi", "processing_core", ] [[package]] name = "processing_pyo3" -version = "0.0.3" +version = "0.0.8" dependencies = [ "bevy", "png", "processing", "processing_cuda", "processing_glfw", + "processing_render", "processing_webcam", "pyo3", - "rand 0.10.1", + "rand 0.10.2", "rand_distr", ] [[package]] name = "processing_render" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "bevy_naga_reflect", "crossbeam-channel", + "fontique 0.7.0", "half", "js-sys", "lyon", "naga", + "notosans", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "parley 0.7.0", "processing_core", - "processing_midi", "raw-window-handle", + "skrifa 0.37.0", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", "wesl", + "wgpu", "windows 0.58.0", ] [[package]] name = "processing_wasm" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "console_error_panic_hook", + "getrandom 0.2.17", + "getrandom 0.3.4", + "getrandom 0.4.3", "js-sys", - "processing_render", + "processing", "wasm-bindgen", "wasm-bindgen-futures", "web-sys", @@ -5778,7 +6259,7 @@ dependencies = [ [[package]] name = "processing_webcam" -version = "0.0.1" +version = "0.0.5" dependencies = [ "bevy", "nannou_webcam", @@ -5788,33 +6269,56 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", - "syn", + "syn 2.0.119", +] + +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if 1.0.4", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", ] +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "pyo3" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "inventory", "libc", @@ -5827,16 +6331,16 @@ dependencies = [ [[package]] name = "pyo3-build-config" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "target-lexicon", ] [[package]] name = "pyo3-ffi" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "libc", "pyo3-build-config", @@ -5844,8 +6348,8 @@ dependencies = [ [[package]] name = "pyo3-introspection" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "anyhow", "goblin", @@ -5855,24 +6359,24 @@ dependencies = [ [[package]] name = "pyo3-macros" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "proc-macro2", "pyo3-macros-backend", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "pyo3-macros-backend" -version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +version = "0.29.2" +source = "git+https://github.com/PyO3/pyo3?branch=main#dfdbc468faf92dbf1594bdeb31bd96fb7c1ef06b" dependencies = [ "heck", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -5892,18 +6396,18 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quick-xml" -version = "0.39.2" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "958f21e8e7ceb5a1aa7fa87fab28e7c75976e0bfe7e23ff069e0a260f894067d" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -5928,9 +6432,9 @@ checksum = "019b4b213425016d7d84a153c4c73afb0946fbb4840e4eece7ba8848b9d6da22" [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha", "rand_core 0.9.5", @@ -5938,12 +6442,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -5979,7 +6483,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d431c2703ccf129de4d45253c03f49ebb22b97d6ad79ee3ecfc7e3f4862c1d8" dependencies = [ "num-traits", - "rand 0.10.1", + "rand 0.10.2", ] [[package]] @@ -6015,10 +6519,10 @@ dependencies = [ "num-traits", "paste", "profiling", - "rand 0.9.4", + "rand 0.9.5", "rand_chacha", "simd_helpers", - "thiserror 2.0.18", + "thiserror 2.0.20", "v_frame", "wasm-bindgen", ] @@ -6038,6 +6542,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.13.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -6078,12 +6591,13 @@ dependencies = [ [[package]] name = "read-fonts" -version = "0.37.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b634fabf032fab15307ffd272149b622260f55974d9fad689292a5d33df02e5" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types", + "core_maths", + "font-types 0.10.1", ] [[package]] @@ -6093,10 +6607,35 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" dependencies = [ "bytemuck", - "core_maths", - "font-types", + "font-types 0.11.3", ] +[[package]] +name = "read-fonts" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +dependencies = [ + "bytemuck", + "font-types 0.12.3", + "once_cell", +] + +[[package]] +name = "realfft" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f821338fddb99d089116342c46e9f1fbf3828dba077674613e734e01d6ea8677" +dependencies = [ + "rustfft", +] + +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "rectangle-pack" version = "0.4.2" @@ -6118,23 +6657,23 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -6144,20 +6683,26 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", "regex-syntax", ] +[[package]] +name = "regex-lite" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" + [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "renderdoc-sys" @@ -6174,27 +6719,38 @@ dependencies = [ "bytemuck", ] +[[package]] +name = "ringbuf" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7" +dependencies = [ + "crossbeam-utils", + "portable-atomic", + "portable-atomic-util", +] + [[package]] name = "rodio" version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ - "cpal", + "cpal 0.17.3", "dasp_sample", "lewton", "num-rational", - "thiserror 2.0.18", + "thiserror 2.0.20", "tracing", ] [[package]] name = "ron" -version = "0.12.1" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" +checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -6202,6 +6758,65 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "roxmltree" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1964b10c76125c36f8afe190065a4bf9a87bf324842c05701330bba9f1cacbb" +dependencies = [ + "memchr", +] + +[[package]] +name = "rtaudio" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7384a8837efe4abea0e53f3887b5072cbbd1ee95c298cff4fbd7a9f3ff23a48b" +dependencies = [ + "bitflags 2.13.1", + "rtaudio-sys", + "thiserror 2.0.20", + "tracing", +] + +[[package]] +name = "rtaudio-sys" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "931ec9a72c48e96e8e2f9868aef1d4e452723354779d01159f7399647427b57b" +dependencies = [ + "cmake", + "pkg-config", +] + +[[package]] +name = "rubato" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6dd52e80cfc21894deadf554a5673002938ae4625f7a283e536f9cf7c17b0d5" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "realfft", +] + +[[package]] +name = "rubato" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f57c655d11e929f05a8663b323ff553f8d9773be05dfdc087795955bedeb8d92" +dependencies = [ + "audioadapter", + "audioadapter-buffers", + "num-complex", + "num-integer", + "num-traits", + "realfft", + "visibility", + "windowfunctions", +] + [[package]] name = "rustc-hash" version = "1.1.0" @@ -6217,13 +6832,27 @@ dependencies = [ "semver", ] +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustix" version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -6236,7 +6865,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -6245,15 +6874,15 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ruzstd" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5ff0cc5e135c8870a775d3320910cd9b564ec036b4dc0b8741629020be63f01" +checksum = "a7c1c839d570d835527c9a5e4db7cb2198683a988cb9d7293fc8674e6bd58fc8" dependencies = [ "twox-hash", ] @@ -6296,7 +6925,7 @@ checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -6326,9 +6955,9 @@ checksum = "cd0b0ec5f1c1ca621c432a25813d8d60c88abe6d3e08a3eb9cf37d97a0fe3d73" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -6336,29 +6965,29 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -6401,17 +7030,23 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" [[package]] name = "simd_cesu8" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" dependencies = [ "rustc_version", "simdutf8", @@ -6434,18 +7069,18 @@ checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "skrifa" -version = "0.40.0" +version = "0.37.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fbdfe3d2475fbd7ddd1f3e5cf8288a30eb3e5f95832829570cd88115a7434ac" +checksum = "8c31071dedf532758ecf3fed987cdb4bd9509f900e026ab684b4ecb81ea49841" dependencies = [ "bytemuck", - "read-fonts 0.37.0", + "read-fonts 0.35.0", ] [[package]] @@ -6458,6 +7093,16 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "skrifa" +version = "0.44.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819ab7d62b1d3e72d9d9dea5650bac30424f9111364bb94928dbf5ecad1baa68" +dependencies = [ + "bytemuck", + "read-fonts 0.41.0", +] + [[package]] name = "slab" version = "0.4.12" @@ -6475,9 +7120,9 @@ dependencies = [ [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" [[package]] name = "smithay-client-toolkit" @@ -6485,7 +7130,7 @@ version = "0.19.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3457dea1f0eb631b4034d61d4d8c32074caa6cd1ab2d59f2327bd8461e2c0016" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "calloop", "calloop-wayland-source", "cursor-icon", @@ -6514,99 +7159,226 @@ dependencies = [ ] [[package]] -name = "spin" -version = "0.9.8" +name = "spin" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" +dependencies = [ + "lock_api", +] + +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "spirv" +version = "0.4.0+sdk-1.4.341.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stackfuture" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +dependencies = [ + "const_panic", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + +[[package]] +name = "strict-num" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "svg_fmt" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" + +[[package]] +name = "swash" +version = "0.2.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "lock_api", + "skrifa 0.44.0", + "yazi", + "zeno", ] [[package]] -name = "spin" -version = "0.10.0" +name = "symphonia" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" dependencies = [ - "portable-atomic", + "lazy_static", + "symphonia-codec-pcm", + "symphonia-codec-vorbis", + "symphonia-core", + "symphonia-format-ogg", + "symphonia-format-riff", + "symphonia-metadata", ] [[package]] -name = "spirv" -version = "0.4.0+sdk-1.4.341.0" +name = "symphonia-codec-pcm" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" +checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" dependencies = [ - "bitflags 2.11.1", + "log", + "symphonia-core", ] [[package]] -name = "stable_deref_trait" -version = "1.2.1" +name = "symphonia-codec-vorbis" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", +] [[package]] -name = "stackfuture" -version = "0.3.1" +name = "symphonia-common" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "115beb9c69db2393ff10b75a1b8587a51716e5551d015001e55320ed279d32f9" +checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" dependencies = [ - "const_panic", + "log", + "symphonia-core", + "symphonia-metadata", ] [[package]] -name = "static_assertions" -version = "1.1.0" +name = "symphonia-core" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "lazy_static", + "log", + "num-complex", + "rustfft", + "smallvec", +] [[package]] -name = "strict-num" -version = "0.1.1" +name = "symphonia-format-ogg" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" +checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" +dependencies = [ + "log", + "symphonia-common", + "symphonia-core", + "symphonia-metadata", +] [[package]] -name = "string_cache" -version = "0.8.9" +name = "symphonia-format-riff" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" dependencies = [ - "new_debug_unreachable", - "parking_lot", - "phf_shared", - "precomputed-hash", + "extended", + "log", + "symphonia-core", + "symphonia-metadata", ] [[package]] -name = "strsim" -version = "0.11.1" +name = "symphonia-metadata" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" +dependencies = [ + "lazy_static", + "log", + "regex-lite", + "smallvec", + "symphonia-core", +] [[package]] -name = "svg_fmt" -version = "0.4.5" +name = "symphonium" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0193cc4331cfd2f3d2011ef287590868599a2f33c3e69bc22c1a3d3acf9e02fb" +checksum = "6cc514081a8a6eb44a4ee5e68d981bf11d976876d0612082121969b31e7d53a8" +dependencies = [ + "fixed-resample", + "symphonia", + "tracing", +] [[package]] -name = "swash" -version = "0.2.7" +name = "syn" +version = "2.0.119" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "842f3cd369c2ba38966204f983eaa5e54a8e84a7d7159ed36ade2b6c335aae64" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" dependencies = [ - "skrifa 0.40.0", - "yazi", - "zeno", + "proc-macro2", + "quote", + "unicode-ident", ] [[package]] name = "syn" -version = "2.0.117" +version = "3.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" dependencies = [ "proc-macro2", "quote", @@ -6621,7 +7393,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -6672,7 +7444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix 1.1.4", "windows-sys 0.61.2", @@ -6707,11 +7479,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.20", ] [[package]] @@ -6722,29 +7494,35 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if 1.0.4", ] +[[package]] +name = "thunderdome" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e170f93360bf9ae6fe3c31116bbf27adb1d054cedd6bc3d7857e34f2d98d0b" + [[package]] name = "tiff" version = "0.11.3" @@ -6786,9 +7564,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -6797,9 +7575,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -6845,30 +7623,42 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.23.10+spec-1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +dependencies = [ + "indexmap", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "winnow 0.7.15", +] + +[[package]] +name = "toml_edit" +version = "0.25.13+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.2", + "winnow 1.0.4", ] [[package]] name = "toml_writer" -version = "1.1.1+spec-1.1.0" +version = "1.1.2+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tracing" @@ -6889,7 +7679,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -6954,6 +7744,25 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + +[[package]] +name = "triple_buffer" +version = "9.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e875ab7068a75b74f419da453927e05527c36f0001b3c7aad3ce443640683487" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "ttf-parser" version = "0.25.1" @@ -6962,9 +7771,9 @@ checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" [[package]] name = "twox-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c" +checksum = "8464ec13c3691491391d9fce00f6416c9a48e46972f72d7865688be2080192c9" [[package]] name = "typeid" @@ -6974,9 +7783,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "typewit" @@ -6992,9 +7801,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -7022,11 +7831,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -7077,7 +7886,7 @@ checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7092,6 +7901,17 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "visibility" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "walkdir" version = "2.5.0" @@ -7110,27 +7930,18 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if 1.0.4", "once_cell", @@ -7141,9 +7952,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -7151,9 +7962,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -7161,65 +7972,31 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn", + "syn 2.0.119", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags 2.11.1", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "wayland-backend" -version = "0.3.15" +version = "0.3.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" +checksum = "016ccf01d1c58b6f8999612813e17c9b2390f7d70671428869913310f83f54b8" dependencies = [ "cc", "downcast-rs 1.2.1", @@ -7231,11 +8008,11 @@ dependencies = [ [[package]] name = "wayland-client" -version = "0.31.14" +version = "0.31.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c7c96bb74690c3189b5c9cb4ca1627062bb23693a4fad9d8c3de958260144" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "rustix 1.1.4", "wayland-backend", "wayland-scanner", @@ -7247,7 +8024,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "625c5029dbd43d25e6aa9615e88b829a5cad13b2819c4ae129fdbb7c31ab4c7e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "cursor-icon", "wayland-backend", ] @@ -7265,11 +8042,11 @@ dependencies = [ [[package]] name = "wayland-protocols" -version = "0.32.12" +version = "0.32.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-scanner", @@ -7281,7 +8058,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b6d8cf1eb2c1c31ed1f5643c88a6e53538129d4af80030c8cabd1f9fa884d91" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7294,7 +8071,7 @@ version = "0.3.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "wayland-backend", "wayland-client", "wayland-protocols", @@ -7303,9 +8080,9 @@ dependencies = [ [[package]] name = "wayland-scanner" -version = "0.31.10" +version = "0.31.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" dependencies = [ "proc-macro2", "quick-xml", @@ -7332,9 +8109,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -7381,7 +8158,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "thiserror 2.0.18", + "thiserror 2.0.20", "wesl-macros", "wgsl-parse", "wgsl-types", @@ -7396,17 +8173,17 @@ dependencies = [ "itertools", "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "wgpu" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72c239a9a747bbd379590985bac952c2e53cb19873f7072b3370c6a6a8e06837" +checksum = "76e8840e1ba2881d4cbb18d2147627a56af426ff064c0401eb0c8410c6325d07" dependencies = [ "arrayvec", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "cfg-if 1.0.4", "cfg_aliases", @@ -7431,14 +8208,14 @@ dependencies = [ [[package]] name = "wgpu-core" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e80ac6cf1895df6342f87d975162108f9d98772a0d74bc404ab7304ac29469e" +checksum = "2f519832254e56965a9940c4af57dcb75f702b6f6fa4a0b172f685395843a4d7" dependencies = [ "arrayvec", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "cfg_aliases", "document-features", @@ -7453,7 +8230,7 @@ dependencies = [ "raw-window-handle", "rustc-hash", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "wgpu-core-deps-apple", "wgpu-core-deps-emscripten", "wgpu-core-deps-wasm", @@ -7465,51 +8242,51 @@ dependencies = [ [[package]] name = "wgpu-core-deps-apple" -version = "29.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43acd053312501689cd92a01a9638d37f3e41a5fd9534875efa8917ee2d11ac0" +checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-emscripten" -version = "29.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef043bf135cc68b6f667c55ff4e345ce2b5924d75bad36a47921b0287ca4b24a" +checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-wasm" -version = "29.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f7b75e72f49035f000dd5262e4126242e92a090a4fd75931ecfe7e60784e6fa" +checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-core-deps-windows-linux-android" -version = "29.0.0" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "725d5c006a8c02967b6d93ef04f6537ec4593313e330cfe86d9d3f946eb90f28" +checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ "wgpu-hal", ] [[package]] name = "wgpu-hal" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89a47aef47636562f3937285af4c44b4b5b404b46577471411cc5313a921da7e" +checksum = "97ace1c17727311c22a46e4e3faf56ea6de81af99dcc839bdfb54857b94d448d" dependencies = [ "android_system_properties", "arrayvec", "ash", "bit-set 0.9.1", - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.6.2", "bytemuck", "cfg-if 1.0.4", @@ -7522,7 +8299,7 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading", + "libloading 0.8.9", "log", "naga", "ndk-sys", @@ -7542,7 +8319,7 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.20", "wasm-bindgen", "wayland-sys", "web-sys", @@ -7550,13 +8327,14 @@ dependencies = [ "wgpu-types", "windows 0.62.2", "windows-core 0.62.2", + "windows-result 0.4.1", ] [[package]] name = "wgpu-naga-bridge" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4684f4410da0cf95a4cb63bb5edaac022461dedb6adf0b64d0d9b5f6890d51" +checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ "naga", "wgpu-types", @@ -7564,11 +8342,11 @@ dependencies = [ [[package]] name = "wgpu-types" -version = "29.0.1" +version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec2675540fb1a5cfa5ef122d3d5f390e2c75711a0b946410f2d6ac3a0f77d1f6" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "bytemuck", "js-sys", "log", @@ -7590,7 +8368,7 @@ dependencies = [ "lalrpop-util", "lexical", "logos", - "thiserror 2.0.18", + "thiserror 2.0.20", "wgsl-types", ] @@ -7649,13 +8427,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] -name = "windows" -version = "0.56.0" +name = "windowfunctions" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1de69df01bdf1ead2f4ac895dc77c9351aefff65b2f3db429a343f9cbf05e132" +checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" dependencies = [ - "windows-core 0.56.0", - "windows-targets 0.52.6", + "num-traits", ] [[package]] @@ -7689,18 +8466,6 @@ dependencies = [ "windows-core 0.62.2", ] -[[package]] -name = "windows-core" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4698e52ed2d08f8658ab0c39512a7c00ee5fe2688c65f8c0a4f06750d729f2a6" -dependencies = [ - "windows-implement 0.56.0", - "windows-interface 0.56.0", - "windows-result 0.1.2", - "windows-targets 0.52.6", -] - [[package]] name = "windows-core" version = "0.58.0" @@ -7738,17 +8503,6 @@ dependencies = [ "windows-threading", ] -[[package]] -name = "windows-implement" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6fc35f58ecd95a9b71c4f2329b911016e6bec66b3f2e6a4aad86bd2e99e2f9b" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - [[package]] name = "windows-implement" version = "0.58.0" @@ -7757,7 +8511,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7768,18 +8522,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn", -] - -[[package]] -name = "windows-interface" -version = "0.56.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08990546bf4edef8f431fa6326e032865f27138718c587dc21bc0265bbcb57cc" -dependencies = [ - "proc-macro2", - "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7790,7 +8533,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7801,7 +8544,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] @@ -7820,15 +8563,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-result" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e383302e8ec8515204254685643de10811af0ed97ea37210dc26fb0032647f8" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-result" version = "0.2.0" @@ -8115,7 +8849,7 @@ dependencies = [ "ahash", "android-activity", "atomic-waker", - "bitflags 2.11.1", + "bitflags 2.13.1", "block2 0.5.1", "bytemuck", "calloop", @@ -8163,23 +8897,17 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - -[[package]] -name = "winnow" -version = "1.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] [[package]] -name = "wit-bindgen" -version = "0.51.0" +name = "winnow" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ - "wit-bindgen-rust-macro", + "memchr", ] [[package]] @@ -8189,90 +8917,27 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" +name = "wmidi" +version = "4.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] +checksum = "2b1b1e28ac6301b1c097d21c11130090f7ecc5db9d5ef4e52595ab942f8d76d5" [[package]] -name = "wit-component" -version = "0.244.0" +name = "writeable" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.11.1", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] -name = "wit-parser" -version = "0.244.0" +name = "x11" +version = "2.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", + "libc", + "pkg-config", ] -[[package]] -name = "writeable" -version = "0.6.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" - [[package]] name = "x11-dl" version = "2.21.0" @@ -8293,7 +8958,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading", + "libloading 0.8.9", "once_cell", "rustix 1.1.4", "x11rb-protocol", @@ -8307,9 +8972,9 @@ checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xcursor" -version = "0.3.10" +version = "0.3.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bec9e4a500ca8864c5b47b8b482a73d62e4237670e5b5f1d6b9e3cae50f28f2b" +checksum = "163b33ed8786455e2fa5d72f554057ce3f3182425434f756cd39c99839d88e23" [[package]] name = "xkbcommon-dl" @@ -8317,7 +8982,7 @@ version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d039de8032a9a8856a6be89cea3e5d12fdd82306ab7c94d74e6deab2460651c5" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.1", "dlib", "log", "once_cell", @@ -8332,9 +8997,9 @@ checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" [[package]] name = "xml-rs" -version = "0.8.28" +version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" +checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "y4m" @@ -8348,11 +9013,22 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e01738255b5a16e78bbb83e7fbba0a1e7dd506905cfc53f4622d89015a03fbb5" +[[package]] +name = "yeslogic-fontconfig-sys" +version = "6.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8b8abf912b9a29ff112e1671c97c33636903d13a69712037190e6805af4f76" +dependencies = [ + "dlib", + "once_cell", + "pkg-config", +] + [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -8367,7 +9043,7 @@ checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] @@ -8379,29 +9055,29 @@ checksum = "6df3dc4292935e51816d896edcd52aa30bc297907c26167fec31e2b0c6a32524" [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", ] [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -8414,15 +9090,15 @@ checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 2.0.119", "synstructure", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -8432,9 +9108,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "serde", "yoke", @@ -8444,26 +9120,26 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "zune-core" -version = "0.5.1" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" [[package]] name = "zune-inflate" diff --git a/Cargo.toml b/Cargo.toml index a8136520..2bae50b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing" -version = "0.0.1" +version = "0.0.8" edition = "2024" autoexamples = false @@ -8,7 +8,7 @@ autoexamples = false workspace = true [features] -default = ["wayland"] +default = ["wayland", "x11"] wayland = ["processing_render/wayland"] x11 = ["processing_render/x11"] webcam = ["dep:processing_webcam"] @@ -23,7 +23,36 @@ type_complexity = "allow" too_many_arguments = "allow" [workspace.dependencies] -bevy = { git = "https://github.com/processing/bevy", branch = "main", features = ["file_watcher", "shader_format_wesl", "free_camera", "pan_camera"] } +bevy = { git = "https://github.com/processing/bevy", branch = "main", default-features = false, features = [ + # 2d + "2d_bevy_render", + "default_app", + "picking", + "scene", + + # 3d + "3d_bevy_render", + + # ui + "ui_api", + "ui_bevy_render", + + # default_platform + "android-game-activity", + "bevy_gilrs", + "bevy_winit", + "default_font", + "multi_threaded", + "std", + "sysinfo_plugin", + "wayland", + "webgl2", + "x11", + "shader_format_wesl", + "free_camera", + "pan_camera", + +] } bevy_naga_reflect = { git = "https://github.com/tychedelia/bevy_naga_reflect" } bevy_cuda = { git = "https://github.com/tychedelia/bevy_cuda" } naga = { version = "29", features = ["wgsl-in"] } @@ -36,6 +65,7 @@ processing_cuda = { path = "crates/processing_cuda" } processing_pyo3 = { path = "crates/processing_pyo3" } processing_render = { path = "crates/processing_render" } processing_midi = { path = "crates/processing_midi" } +processing_audio = { path = "crates/processing_audio" } processing_input = { path = "crates/processing_input" } processing_glfw = { path = "crates/processing_glfw" } processing_webcam = { path = "crates/processing_webcam" } @@ -47,6 +77,7 @@ bevy = { workspace = true } processing_core = { workspace = true } processing_render = { workspace = true } processing_midi = { workspace = true } +processing_audio = { workspace = true } processing_input = { workspace = true } processing_webcam = { workspace = true, optional = true } processing_cuda = { workspace = true, optional = true } @@ -65,9 +96,25 @@ rand = { workspace = true } [target.'cfg(target_os = "linux")'.dev-dependencies] processing_glfw = { workspace = true, features = ["wayland"] } +## TODO: Remove these patches once we've moved back to depending on upstream bevy [patch."https://github.com/bevyengine/bevy"] bevy = { git = "https://github.com/processing/bevy", branch = "main" } +[patch.crates-io] +bevy = { git = "https://github.com/processing/bevy", branch = "main" } +# bevy_seedling depends on bevy sub-crates directly, so they must be patched too +bevy_app = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_asset = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_diagnostic = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_ecs = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_log = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_math = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_platform = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_reflect = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_time = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_transform = { git = "https://github.com/processing/bevy", branch = "main" } +bevy_utils = { git = "https://github.com/processing/bevy", branch = "main" } + [[example]] name = "rectangle" path = "examples/rectangle.rs" @@ -161,6 +208,81 @@ path = "examples/blend_modes.rs" name = "camera_controllers" path = "examples/camera_controllers.rs" +[[example]] +name = "compute_readback" +path = "examples/compute_readback.rs" + +[[example]] +name = "alias_spike" +path = "examples/alias_spike.rs" + +[[example]] +name = "particles_basic" +path = "examples/particles_basic.rs" + +[[example]] +name = "particles_animated" +path = "examples/particles_animated.rs" + +[[example]] +name = "particles_oriented" +path = "examples/particles_oriented.rs" + +[[example]] +name = "particles_colored" +path = "examples/particles_colored.rs" + +[[example]] +name = "particles_colored_pbr" +path = "examples/particles_colored_pbr.rs" + +[[example]] +name = "particles_emit" +path = "examples/particles_emit.rs" + +[[example]] +name = "particles_lifecycle" +path = "examples/particles_lifecycle.rs" + +[[example]] +name = "particles_from_mesh" +path = "examples/particles_from_mesh.rs" + +[[example]] +name = "particles_noise" +path = "examples/particles_noise.rs" + +[[example]] +name = "particles_emit_gpu" +path = "examples/particles_emit_gpu.rs" + +[[example]] +name = "particles_stress" +path = "examples/particles_stress.rs" + +[[example]] +name = "particles_scatter" +path = "examples/particles_scatter.rs" + +[[example]] +name = "particles_scatter_volume" +path = "examples/particles_scatter_volume.rs" + +[[example]] +name = "particles_text_whirl" +path = "examples/particles_text_whirl.rs" + +[[example]] +name = "text" +path = "examples/text.rs" + +[[example]] +name = "text_3d" +path = "examples/text_3d.rs" + +[[example]] +name = "filter" +path = "examples/filter.rs" [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/assets/shaders/density_color.wesl b/assets/shaders/density_color.wesl new file mode 100644 index 00000000..df2f1f2b --- /dev/null +++ b/assets/shaders/density_color.wesl @@ -0,0 +1,23 @@ +struct Params { + scale: f32, +} + +@group(0) @binding(0) var density: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&density) { return; } + + let t = clamp(density[i] * params.scale, 0.0, 1.0); + let cool = vec3(0.10, 0.22, 0.70); + let warm = vec3(1.00, 0.55, 0.15); + let rgb = mix(cool, warm, t) * (0.35 + 1.1 * t); + + color[i * 4u + 0u] = rgb.x; + color[i * 4u + 1u] = rgb.y; + color[i * 4u + 2u] = rgb.z; + color[i * 4u + 3u] = 1.0; +} diff --git a/assets/shaders/flocking_duck_flock.wesl b/assets/shaders/flocking_duck_flock.wesl new file mode 100644 index 00000000..3e3fec97 --- /dev/null +++ b/assets/shaders/flocking_duck_flock.wesl @@ -0,0 +1,82 @@ +struct Params { + neighbor_dist: f32, + separation_dist: f32, + max_speed: f32, + max_force: f32, + home_radius: f32, + _pad0: f32, + _pad1: f32, + _pad2: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var home: array; +@group(0) @binding(3) var steer: array; +@group(0) @binding(4) var params: Params; + +fn limit(v: vec3, max_len: f32) -> vec3 { + let len = length(v); + if len > max_len { return v * (max_len / len); } + return v; +} + +// Reynolds: steering = desired - velocity +fn steer_toward(desired: vec3, vel: vec3) -> vec3 { + let len = length(desired); + if len < 1e-6 { return vec3(0.0); } + return limit(desired * (params.max_speed / len) - vel, params.max_force); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + + var separation = vec3(0.0); + var alignment = vec3(0.0); + var cohesion = vec3(0.0); + var separation_count = 0u; + var neighbor_count = 0u; + + for (var j = 0u; j < count; j = j + 1u) { + if j == i { continue; } + let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); + let d = distance(pos, other); + if d > 0.0 && d < params.separation_dist { + // Point away from the neighbor, weighted by closeness + separation = separation + normalize(pos - other) / d; + separation_count = separation_count + 1u; + } + if d < params.neighbor_dist { + alignment = alignment + + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); + cohesion = cohesion + other; + neighbor_count = neighbor_count + 1u; + } + } + + var force = vec3(0.0); + if separation_count > 0u { + force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; + } + if neighbor_count > 0u { + force = force + steer_toward(alignment, vel); + force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); + } + + // The tether: inside home_radius the weight is < 1 and flocking wins; + // past it the pull grows quadratically until it dominates everything. + let home_pos = vec3(home[i * 3u], home[i * 3u + 1u], home[i * 3u + 2u]); + let to_home = home_pos - pos; + let w = length(to_home) / params.home_radius; + force = force + steer_toward(to_home, vel) * min(w * w, 8.0); + + steer[i * 3u] = force.x; + steer[i * 3u + 1u] = force.y; + steer[i * 3u + 2u] = force.z; +} diff --git a/assets/shaders/flocking_duck_integrate.wesl b/assets/shaders/flocking_duck_integrate.wesl new file mode 100644 index 00000000..a38e92b6 --- /dev/null +++ b/assets/shaders/flocking_duck_integrate.wesl @@ -0,0 +1,51 @@ +struct Params { + dt: f32, + max_speed: f32, + _pad0: f32, + _pad1: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var rotation: array; +@group(0) @binding(4) var params: Params; + +// shortest-arc quaternion rotating the mesh's +Z axis onto dir +fn quat_z_to(dir: vec3) -> vec4 { + let z = vec3(0.0, 0.0, 1.0); + let d = dot(z, dir); + if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } + return normalize(vec4(cross(z, dir), 1.0 + d)); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + var pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); + + vel = vel + force * params.dt; + let speed = length(vel); + if speed > params.max_speed { vel = vel * (params.max_speed / speed); } + pos = pos + vel * params.dt; + + position[i * 3u] = pos.x; + position[i * 3u + 1u] = pos.y; + position[i * 3u + 2u] = pos.z; + velocity[i * 3u] = vel.x; + velocity[i * 3u + 1u] = vel.y; + velocity[i * 3u + 2u] = vel.z; + + if speed > 1e-6 { + let q = quat_z_to(vel / speed); + rotation[i * 4u] = q.x; + rotation[i * 4u + 1u] = q.y; + rotation[i * 4u + 2u] = q.z; + rotation[i * 4u + 3u] = q.w; + } +} diff --git a/assets/shaders/flocking_gpu_flock.wesl b/assets/shaders/flocking_gpu_flock.wesl new file mode 100644 index 00000000..e0852c19 --- /dev/null +++ b/assets/shaders/flocking_gpu_flock.wesl @@ -0,0 +1,70 @@ +struct Params { + neighbor_dist: f32, + separation_dist: f32, + max_speed: f32, + max_force: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var params: Params; + +fn limit(v: vec3, max_len: f32) -> vec3 { + let len = length(v); + if len > max_len { return v * (max_len / len); } + return v; +} + +// Reynolds: steering = desired - velocity +fn steer_toward(desired: vec3, vel: vec3) -> vec3 { + let len = length(desired); + if len < 1e-6 { return vec3(0.0); } + return limit(desired * (params.max_speed / len) - vel, params.max_force); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + + var separation = vec3(0.0); + var alignment = vec3(0.0); + var cohesion = vec3(0.0); + var separation_count = 0u; + var neighbor_count = 0u; + + for (var j = 0u; j < count; j = j + 1u) { + if j == i { continue; } + let other = vec3(position[j * 3u], position[j * 3u + 1u], position[j * 3u + 2u]); + let d = distance(pos, other); + if d > 0.0 && d < params.separation_dist { + // Point away from the neighbor, weighted by closeness + separation = separation + normalize(pos - other) / d; + separation_count = separation_count + 1u; + } + if d < params.neighbor_dist { + alignment = alignment + + vec3(velocity[j * 3u], velocity[j * 3u + 1u], velocity[j * 3u + 2u]); + cohesion = cohesion + other; + neighbor_count = neighbor_count + 1u; + } + } + + var force = vec3(0.0); + if separation_count > 0u { + force = force + steer_toward(separation / f32(separation_count), vel) * 1.5; + } + if neighbor_count > 0u { + force = force + steer_toward(alignment, vel); + force = force + steer_toward(cohesion / f32(neighbor_count) - pos, vel); + } + + steer[i * 3u] = force.x; + steer[i * 3u + 1u] = force.y; + steer[i * 3u + 2u] = force.z; +} diff --git a/assets/shaders/flocking_gpu_integrate.wesl b/assets/shaders/flocking_gpu_integrate.wesl new file mode 100644 index 00000000..bccbc162 --- /dev/null +++ b/assets/shaders/flocking_gpu_integrate.wesl @@ -0,0 +1,55 @@ +struct Params { + dt: f32, + max_speed: f32, + bound: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var steer: array; +@group(0) @binding(3) var rotation: array; +@group(0) @binding(4) var params: Params; + +// shortest-arc quaternion rotating the mesh's +Z axis onto dir +fn quat_z_to(dir: vec3) -> vec4 { + let z = vec3(0.0, 0.0, 1.0); + let d = dot(z, dir); + if d < -0.9999 { return vec4(0.0, 1.0, 0.0, 0.0); } + return normalize(vec4(cross(z, dir), 1.0 + d)); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + var pos = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + var vel = vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); + let force = vec3(steer[i * 3u], steer[i * 3u + 1u], steer[i * 3u + 2u]); + + vel = vel + force * params.dt; + let speed = length(vel); + if speed > params.max_speed { vel = vel * (params.max_speed / speed); } + pos = pos + vel * params.dt; + + // wrap into [-bound, bound]: ((p + b) mod 2b + 2b) mod 2b - b + let span = 2.0 * params.bound; + pos = ((pos + params.bound) % span + span) % span - params.bound; + + position[i * 3u] = pos.x; + position[i * 3u + 1u] = pos.y; + position[i * 3u + 2u] = pos.z; + velocity[i * 3u] = vel.x; + velocity[i * 3u + 1u] = vel.y; + velocity[i * 3u + 2u] = vel.z; + + if speed > 1e-6 { + let q = quat_z_to(vel / speed); + rotation[i * 4u] = q.x; + rotation[i * 4u + 1u] = q.y; + rotation[i * 4u + 2u] = q.z; + rotation[i * 4u + 3u] = q.w; + } +} diff --git a/assets/shaders/gen_colored_surface.wesl b/assets/shaders/gen_colored_surface.wesl new file mode 100644 index 00000000..aba05674 --- /dev/null +++ b/assets/shaders/gen_colored_surface.wesl @@ -0,0 +1,65 @@ +struct Params { + nx: u32, + ny: u32, + time: f32, + extent: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var normal: array; +@group(0) @binding(3) var indices: array; +@group(0) @binding(4) var params: Params; + +fn height(x: f32, z: f32, t: f32) -> f32 { + let r = sqrt(x * x + z * z); + return sin(r * 0.8 - t * 2.0) * 1.6 * exp(-r * 0.10) + + sin(x * 0.5 + t) * 0.4 + + cos(z * 0.6 - t * 1.3) * 0.3; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let nx = params.nx; + let ny = params.ny; + if i >= nx * ny { return; } + + let gx = i % nx; + let gy = i / nx; + let x = (f32(gx) / f32(nx - 1u) - 0.5) * params.extent; + let z = (f32(gy) / f32(ny - 1u) - 0.5) * params.extent; + let t = params.time; + + let h = height(x, z, t); + position[i * 3u + 0u] = x; + position[i * 3u + 1u] = h; + position[i * 3u + 2u] = z; + + let e = 0.12; + let hl = height(x - e, z, t); + let hr = height(x + e, z, t); + let hd = height(x, z - e, t); + let hu = height(x, z + e, t); + let n = normalize(vec3(hl - hr, 2.0 * e, hd - hu)); + normal[i * 3u + 0u] = n.x; + normal[i * 3u + 1u] = n.y; + normal[i * 3u + 2u] = n.z; + + let c = clamp(h * 0.35 + 0.5, 0.0, 1.0); + color[i * 4u + 0u] = 0.15 + 0.75 * c; + color[i * 4u + 1u] = 0.30 + 0.45 * (1.0 - abs(c - 0.5) * 2.0); + color[i * 4u + 2u] = 0.95 - 0.65 * c; + color[i * 4u + 3u] = 1.0; + + if gx + 1u < nx && gy + 1u < ny { + let cell = gy * (nx - 1u) + gx; + let base = cell * 6u; + indices[base + 0u] = i; + indices[base + 1u] = i + 1u; + indices[base + 2u] = i + nx + 1u; + indices[base + 3u] = i; + indices[base + 4u] = i + nx + 1u; + indices[base + 5u] = i + nx; + } +} diff --git a/assets/shaders/gen_surface.wesl b/assets/shaders/gen_surface.wesl new file mode 100644 index 00000000..7932c72f --- /dev/null +++ b/assets/shaders/gen_surface.wesl @@ -0,0 +1,60 @@ +struct Params { + nx: u32, + ny: u32, + time: f32, + extent: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var indices: array; +@group(0) @binding(2) var params: Params; + +fn wave(p: vec2, dir: vec2, freq: f32, speed: f32, t: f32) -> f32 { + return sin(dot(p, normalize(dir)) * freq + t * speed); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let nx = params.nx; + let ny = params.ny; + if i >= nx * ny { return; } + + let gx = i % nx; + let gy = i / nx; + + let u = f32(gx) / f32(nx - 1u); + let v = f32(gy) / f32(ny - 1u); + let x = (u - 0.5) * params.extent; + let z = (v - 0.5) * params.extent; + + let t = params.time; + let p = vec2(x, z); + + let w1 = wave(p, vec2(1.0, 0.3), 0.6, 0.7, t) * 1.0; + let w2 = wave(p, vec2(0.4, 1.0), 0.9, 0.9, t) * 0.6; + let w3 = wave(p, vec2(-0.7, 0.6), 1.7, 1.4, t) * 0.3; + let w4 = wave(p, vec2(0.9, -0.5), 2.3, 1.8, t) * 0.18; + let w5 = wave(p, vec2(-0.2, -1.0), 4.1, 2.6, t) * 0.08; + var h = w1 + w2 + w3 + w4 + w5; + h = h + 0.25 * h * abs(h); + + position[i * 3u + 0u] = x; + position[i * 3u + 1u] = h; + position[i * 3u + 2u] = z; + + if gx + 1u < nx && gy + 1u < ny { + let cell = gy * (nx - 1u) + gx; + let base = cell * 6u; + let v00 = i; + let v10 = i + 1u; + let v01 = i + nx; + let v11 = i + nx + 1u; + indices[base + 0u] = v00; + indices[base + 1u] = v10; + indices[base + 2u] = v11; + indices[base + 3u] = v00; + indices[base + 4u] = v11; + indices[base + 5u] = v01; + } +} \ No newline at end of file diff --git a/assets/shaders/particles_animated_spin.wesl b/assets/shaders/particles_animated_spin.wesl new file mode 100644 index 00000000..cf137c27 --- /dev/null +++ b/assets/shaders/particles_animated_spin.wesl @@ -0,0 +1,21 @@ +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} diff --git a/assets/shaders/particles_emit_gpu_motion.wesl b/assets/shaders/particles_emit_gpu_motion.wesl new file mode 100644 index 00000000..fc334987 --- /dev/null +++ b/assets/shaders/particles_emit_gpu_motion.wesl @@ -0,0 +1,37 @@ +struct Params { + dt: f32, + ttl: f32, + gravity: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var scale: array; +@group(0) @binding(3) var age: array; +@group(0) @binding(4) var life: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + if life[i] <= 0.0 { return; } + + age[i] = age[i] + params.dt; + + velocity[i * 3u + 1u] = velocity[i * 3u + 1u] - params.gravity * params.dt; + + position[i * 3u + 0u] = position[i * 3u + 0u] + velocity[i * 3u + 0u] * params.dt; + position[i * 3u + 1u] = position[i * 3u + 1u] + velocity[i * 3u + 1u] * params.dt; + position[i * 3u + 2u] = position[i * 3u + 2u] + velocity[i * 3u + 2u] * params.dt; + + let remaining = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = remaining * remaining; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { life[i] = 0.0; } +} diff --git a/assets/shaders/particles_emit_gpu_spawn.wesl b/assets/shaders/particles_emit_gpu_spawn.wesl new file mode 100644 index 00000000..15d8f2a4 --- /dev/null +++ b/assets/shaders/particles_emit_gpu_spawn.wesl @@ -0,0 +1,65 @@ +struct Spawn { + pos: vec4, + speed: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var color: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var life: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_base: u32; +@group(0) @binding(8) var emit_count: u32; +@group(0) @binding(9) var emit_capacity: u32; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= emit_count { return; } + let slot = (emit_base + local_i) % emit_capacity; + + let seed = emit_base + local_i; + + let theta = hash_unit(seed) * 6.2831853; + let r = sqrt(hash_unit(seed * 2u + 1u)); + let dirxz = vec2(cos(theta), sin(theta)) * r; + let dy = 0.7 + 0.3 * hash_unit(seed * 3u + 7u); + let v = vec3(dirxz.x, dy, dirxz.y) * spawn.speed.x; + + position[slot * 3u + 0u] = spawn.pos.x; + position[slot * 3u + 1u] = spawn.pos.y; + position[slot * 3u + 2u] = spawn.pos.z; + + velocity[slot * 3u + 0u] = v.x; + velocity[slot * 3u + 1u] = v.y; + velocity[slot * 3u + 2u] = v.z; + + let h = fract(hash_unit(seed * 5u + 11u)); + color[slot * 4u + 0u] = 0.5 + 0.5 * sin(h * 6.28); + color[slot * 4u + 1u] = 0.5 + 0.5 * sin(h * 6.28 + 2.094); + color[slot * 4u + 2u] = 0.5 + 0.5 * sin(h * 6.28 + 4.189); + color[slot * 4u + 3u] = 1.0; + + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + + age[slot] = 0.0; + life[slot] = 1.0; +} diff --git a/assets/shaders/particles_lifecycle_aging.wesl b/assets/shaders/particles_lifecycle_aging.wesl new file mode 100644 index 00000000..4ef6816d --- /dev/null +++ b/assets/shaders/particles_lifecycle_aging.wesl @@ -0,0 +1,33 @@ +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var life: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var params: vec4; // x = dt, y = ttl + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { + return; + } + let dt = params.x; + let ttl = params.y; + + if life[i] <= 0.0 { + return; + } + + age[i] = age[i] + dt; + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + let remaining = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = remaining * remaining; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + life[i] = 0.0; + } +} diff --git a/assets/shaders/plexus_curve.wesl b/assets/shaders/plexus_curve.wesl new file mode 100644 index 00000000..3885facc --- /dev/null +++ b/assets/shaders/plexus_curve.wesl @@ -0,0 +1,42 @@ +import { lygia::color::space::hsv2rgb::hsv2rgb }; + +struct Params { + count: u32, + time: f32, + fx: f32, // frequency ratios + fy: f32, + fz: f32, + loops: f32, // parameter spans loops * TAU + scale: f32, + hue_mix: f32, // 0 = grayscale (black), 1 = rainbow +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var params: Params; + +const TAU: f32 = 6.28318530718; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= params.count { return; } + + let u = f32(i) / f32(params.count); + let t = u * TAU * params.loops; + let world = vec3( + sin(params.fx * t + params.time * 0.30), + sin(params.fy * t + params.time * 0.47), + sin(params.fz * t + params.time * 0.23), + ) * params.scale; + position[i * 3u + 0u] = world.x; + position[i * 3u + 1u] = world.y; + position[i * 3u + 2u] = world.z; + + let rainbow = hsv2rgb(vec3(fract(u + params.time * 0.03), 0.75, 1.0)); + let rgb = mix(vec3(0.0), rainbow, params.hue_mix); + color[i * 4u + 0u] = rgb.x; + color[i * 4u + 1u] = rgb.y; + color[i * 4u + 2u] = rgb.z; + color[i * 4u + 3u] = 1.0; +} diff --git a/assets/shaders/plexus_link.wesl b/assets/shaders/plexus_link.wesl new file mode 100644 index 00000000..9b375f0b --- /dev/null +++ b/assets/shaders/plexus_link.wesl @@ -0,0 +1,114 @@ +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +struct Params { + connection_radius: f32, + connection_ramp: f32, // falloff exponent + line_alpha: f32, // opacity scale + max_links: u32, // per-particle edge cap +} + +// Auto-bound by apply(): names must be position/color. +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: array; +@group(0) @binding(2) var edge_pos: array; +@group(0) @binding(3) var edge_col: array; +@group(0) @binding(4) var indices: array; +@group(0) @binding(5) var draw_args: array>; +// Bound by grid.bind(). +@group(0) @binding(6) var offsets: array; +@group(0) @binding(7) var sorted: array; +@group(0) @binding(8) var params: Params; +@group(0) @binding(9) var gp: GridParams; + +fn cell_coords(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> vec3 { + let rel = (p - grid_min) / cell_size; + return vec3( + clamp(i32(floor(rel.x)), 0, i32(dims.x) - 1), + clamp(i32(floor(rel.y)), 0, i32(dims.y) - 1), + clamp(i32(floor(rel.z)), 0, i32(dims.z) - 1), + ); +} + +fn cell_index(c: vec3, dims: vec3) -> u32 { + return c.x + c.y * dims.x + c.z * dims.x * dims.y; +} + +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +fn load_rgb(i: u32) -> vec3 { + return vec3(color[i * 4u], color[i * 4u + 1u], color[i * 4u + 2u]); +} + +fn emit_vertex(slot: u32, p: vec3, rgb: vec3, a: f32) { + edge_pos[slot * 3u + 0u] = p.x; + edge_pos[slot * 3u + 1u] = p.y; + edge_pos[slot * 3u + 2u] = p.z; + edge_col[slot * 4u + 0u] = rgb.x; + edge_col[slot * 4u + 1u] = rgb.y; + edge_col[slot * 4u + 2u] = rgb.z; + edge_col[slot * 4u + 3u] = a; + indices[slot] = slot; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let cap = arrayLength(&indices); + let pos = load_pos(i); + let rgb = load_rgb(i); + let radius = params.connection_radius; + let r2 = radius * radius; + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + + let reach = max(1, i32(ceil(radius / gp.cell_size))); + let z0 = max(base.z - reach, 0); + let z1 = min(base.z + reach, i32(gp.dims_z) - 1); + let y0 = max(base.y - reach, 0); + let y1 = min(base.y + reach, i32(gp.dims_y) - 1); + let x0 = max(base.x - reach, 0); + let x1 = min(base.x + reach, i32(gp.dims_x) - 1); + + var emitted = 0u; + for (var cz = z0; cz <= z1; cz++) { + for (var cy = y0; cy <= y1; cy++) { + for (var cx = x0; cx <= x1; cx++) { + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + if j <= i { continue; } // one direction per edge + let pj = load_pos(j); + let diff = pos - pj; + let d2 = dot(diff, diff); + if d2 > r2 { continue; } + + if emitted >= params.max_links { return; } + emitted += 1u; + + let d = sqrt(d2); + let a = pow(1.0 / (d / radius + 1.0), params.connection_ramp) * params.line_alpha; + + let slot = atomicAdd(&draw_args[0], 2u); + if slot + 1u < cap { + emit_vertex(slot, pos, rgb, a); + emit_vertex(slot + 1u, pj, load_rgb(j), a); + } + } + } + } + } +} diff --git a/crates/processing_audio/Cargo.toml b/crates/processing_audio/Cargo.toml new file mode 100644 index 00000000..ebc8ad8c --- /dev/null +++ b/crates/processing_audio/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "processing_audio" +version = "0.1.0" +edition = "2024" + +[dependencies] +bevy = { workspace = true } +bevy_seedling = { git = "https://github.com/CorvusPrudens/bevy_seedling", branch = "master" } + +[lints] +workspace = true diff --git a/crates/processing_audio/src/lib.rs b/crates/processing_audio/src/lib.rs new file mode 100644 index 00000000..b9620636 --- /dev/null +++ b/crates/processing_audio/src/lib.rs @@ -0,0 +1,10 @@ +use bevy::prelude::*; +use bevy_seedling::prelude::*; + +pub struct AudioPlugin; + +impl Plugin for AudioPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(SeedlingPlugins); + } +} diff --git a/crates/processing_core/Cargo.toml b/crates/processing_core/Cargo.toml index f563035e..93ad03a2 100644 --- a/crates/processing_core/Cargo.toml +++ b/crates/processing_core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_core" -version = "0.0.1" +version = "0.0.5" edition = "2024" [lints] diff --git a/crates/processing_core/src/constants.rs b/crates/processing_core/src/constants.rs new file mode 100644 index 00000000..f8081bce --- /dev/null +++ b/crates/processing_core/src/constants.rs @@ -0,0 +1,212 @@ +pub const CORNER: &str = "corner"; +pub const CORNERS: &str = "corners"; +pub const RADIUS: &str = "radius"; +pub const CENTER: &str = "center"; // also the middle mouse button + +pub const ROUND: &str = "round"; // caps and joins +pub const SQUARE: &str = "square"; +pub const PROJECT: &str = "project"; +pub const MITER: &str = "miter"; +pub const BEVEL: &str = "bevel"; + +pub const POLYGON: &str = "polygon"; +pub const POINTS: &str = "points"; +pub const LINES: &str = "lines"; +pub const TRIANGLES: &str = "triangles"; +pub const TRIANGLE_FAN: &str = "triangle_fan"; +pub const TRIANGLE_STRIP: &str = "triangle_strip"; +pub const QUADS: &str = "quads"; +pub const QUAD_STRIP: &str = "quad_strip"; +pub const LINE_STRIP: &str = "line_strip"; // geometry topology; no begin_shape equivalent + +pub const OPEN: &str = "open"; +pub const CHORD: &str = "chord"; +pub const PIE: &str = "pie"; +pub const CLOSE: bool = true; + +pub const LEFT: &str = "left"; +pub const RIGHT: &str = "right"; + +pub const TOP: &str = "top"; // vertical text align +pub const BOTTOM: &str = "bottom"; +pub const BASELINE: &str = "baseline"; + +pub const NORMAL: &str = "normal"; // text style +pub const ITALIC: &str = "italic"; +pub const BOLD: &str = "bold"; +pub const BOLD_ITALIC: &str = "bold_italic"; + +pub const WORD: &str = "word"; // text wrap mode +pub const CHAR: &str = "char"; + +pub const NEAREST: &str = "nearest"; +pub const CLAMP: &str = "clamp"; +pub const REPEAT: &str = "repeat"; +pub const MIRROR: &str = "mirror"; + +pub const SRGB: &str = "srgb"; +pub const LINEAR: &str = "linear"; // also the Sampler filter +pub const HSL: &str = "hsl"; +pub const HSV: &str = "hsv"; +pub const HWB: &str = "hwb"; +pub const OKLAB: &str = "oklab"; +pub const OKLCH: &str = "oklch"; +pub const LAB: &str = "lab"; +pub const LCH: &str = "lch"; +pub const XYZ: &str = "xyz"; + +pub const MAP: &str = "map"; +pub const COMBINE: &str = "combine"; +pub const MIX: &str = "mix"; +pub const LOOKUP: &str = "lookup"; +pub const REDUCE: &str = "reduce"; +pub const EXTRACT: &str = "extract"; +pub const PACK: &str = "pack"; +pub const GENERATE: &str = "generate"; +pub const NEIGHBOR: &str = "neighbor"; + +pub const COUNT: &str = "count"; +pub const DENSITY: &str = "density"; + +pub const CONSTANT: &str = "constant"; +pub const SMOOTHSTEP: &str = "smoothstep"; +pub const QUADRATIC: &str = "quadratic"; +pub const CUBIC: &str = "cubic"; +pub const INVERSE: &str = "inverse"; + +pub const AFFINE: &str = "affine"; +pub const ABS: &str = "abs"; +pub const NEGATE: &str = "negate"; +pub const FLOOR: &str = "floor"; +pub const SQRT: &str = "sqrt"; +pub const GREATER: &str = "greater"; +pub const LESS: &str = "less"; +pub const GEQ: &str = "geq"; +pub const LEQ: &str = "leq"; +pub const EQ: &str = "eq"; +pub const NEQ: &str = "neq"; + +pub const ADD: &str = "add"; +pub const SUB: &str = "sub"; +pub const MUL: &str = "mul"; +pub const DIV: &str = "div"; +pub const POW: &str = "pow"; + +pub const LENGTH: &str = "length"; +pub const SUM: &str = "sum"; +pub const SUMSQ: &str = "sumsq"; +pub const MEAN: &str = "mean"; +pub const MIN: &str = "min"; +pub const MAX: &str = "max"; + +pub const UNIFORM: &str = "uniform"; +pub const SIGNED: &str = "signed"; +pub const GAUSSIAN: &str = "gaussian"; + +pub const NOISE: &str = "noise"; +pub const TRANSFORM: &str = "transform"; +pub const ATTRACT: &str = "attract"; +pub const DRAG: &str = "drag"; +pub const VORTEX: &str = "vortex"; +pub const FORCE: &str = "force"; +pub const INTEGRATE: &str = "integrate"; +pub const AGE: &str = "age"; +pub const IMPULSE: &str = "impulse"; +pub const ORIENT: &str = "orient"; +pub const FIELD: &str = "field"; +pub const BOUNDS_SPHERE: &str = "bounds_sphere"; +pub const BOUNDS_BOX: &str = "bounds_box"; + +pub const PI: f32 = std::f32::consts::PI; +pub const TWO_PI: f32 = std::f32::consts::TAU; +pub const HALF_PI: f32 = std::f32::consts::FRAC_PI_2; +pub const QUARTER_PI: f32 = std::f32::consts::FRAC_PI_4; +pub const TAU: f32 = std::f32::consts::TAU; +pub const DEG_TO_RAD: f32 = std::f32::consts::PI / 180.0; +pub const RAD_TO_DEG: f32 = 180.0 / std::f32::consts::PI; + +// Key codes stay numeric (compared against `key_code`); values must match `key_code_to_u32`. + +pub const KEY_A: u32 = 65; +pub const KEY_B: u32 = 66; +pub const KEY_C: u32 = 67; +pub const KEY_D: u32 = 68; +pub const KEY_E: u32 = 69; +pub const KEY_F: u32 = 70; +pub const KEY_G: u32 = 71; +pub const KEY_H: u32 = 72; +pub const KEY_I: u32 = 73; +pub const KEY_J: u32 = 74; +pub const KEY_K: u32 = 75; +pub const KEY_L: u32 = 76; +pub const KEY_M: u32 = 77; +pub const KEY_N: u32 = 78; +pub const KEY_O: u32 = 79; +pub const KEY_P: u32 = 80; +pub const KEY_Q: u32 = 81; +pub const KEY_R: u32 = 82; +pub const KEY_S: u32 = 83; +pub const KEY_T: u32 = 84; +pub const KEY_U: u32 = 85; +pub const KEY_V: u32 = 86; +pub const KEY_W: u32 = 87; +pub const KEY_X: u32 = 88; +pub const KEY_Y: u32 = 89; +pub const KEY_Z: u32 = 90; + +pub const KEY_0: u32 = 48; +pub const KEY_1: u32 = 49; +pub const KEY_2: u32 = 50; +pub const KEY_3: u32 = 51; +pub const KEY_4: u32 = 52; +pub const KEY_5: u32 = 53; +pub const KEY_6: u32 = 54; +pub const KEY_7: u32 = 55; +pub const KEY_8: u32 = 56; +pub const KEY_9: u32 = 57; + +pub const SPACE: u32 = 32; +pub const QUOTE: u32 = 39; +pub const COMMA: u32 = 44; +pub const MINUS: u32 = 45; +pub const PERIOD: u32 = 46; +pub const SLASH: u32 = 47; +pub const SEMICOLON: u32 = 59; +pub const EQUAL: u32 = 61; +pub const BRACKET_LEFT: u32 = 91; +pub const BACKSLASH: u32 = 92; +pub const BRACKET_RIGHT: u32 = 93; +pub const BACKQUOTE: u32 = 96; + +pub const ESCAPE: u32 = 256; +pub const ENTER: u32 = 257; +pub const TAB: u32 = 258; +pub const BACKSPACE: u32 = 259; +pub const INSERT: u32 = 260; +pub const DELETE: u32 = 261; +pub const UP: u32 = 265; +pub const DOWN: u32 = 264; +pub const LEFT_ARROW: u32 = 263; +pub const RIGHT_ARROW: u32 = 262; +pub const PAGE_UP: u32 = 266; +pub const PAGE_DOWN: u32 = 267; +pub const HOME: u32 = 268; +pub const END: u32 = 269; + +pub const SHIFT: u32 = 340; +pub const CONTROL: u32 = 341; +pub const ALT: u32 = 342; +pub const SUPER: u32 = 343; + +pub const F1: u32 = 290; +pub const F2: u32 = 291; +pub const F3: u32 = 292; +pub const F4: u32 = 293; +pub const F5: u32 = 294; +pub const F6: u32 = 295; +pub const F7: u32 = 296; +pub const F8: u32 = 297; +pub const F9: u32 = 298; +pub const F10: u32 = 299; +pub const F11: u32 = 300; +pub const F12: u32 = 301; diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 7cc1e9e4..41510f34 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -32,8 +32,8 @@ pub enum ProcessingError { TransformNotFound, #[error("Material not found")] MaterialNotFound, - #[error("Unknown material property: {0}")] - UnknownMaterialProperty(String), + #[error("Unknown shader property: {0}")] + UnknownShaderProperty(String), #[error("GLTF load error: {0}")] GltfLoadError(String), #[error("Webcam not connected")] @@ -46,4 +46,22 @@ pub enum ProcessingError { MidiPortNotFound(usize), #[error("CUDA error: {0}")] CudaError(String), + #[error("Compute shader not found")] + ComputeNotFound, + #[error("Filter not found")] + FilterNotFound, + #[error("Buffer not found")] + BufferNotFound, + #[error("Buffer map error: {0}")] + BufferMapError(String), + #[error("Pipeline compile error: {0}")] + PipelineCompileError(String), + #[error("Pipeline not ready after {0} frames")] + PipelineNotReady(u32), + #[error("Particles not found")] + ParticlesNotFound, + #[error("Font not found")] + FontNotFound, + #[error("Font load error: {0}")] + FontLoadError(String), } diff --git a/crates/processing_core/src/lib.rs b/crates/processing_core/src/lib.rs index 9de4d392..489754e7 100644 --- a/crates/processing_core/src/lib.rs +++ b/crates/processing_core/src/lib.rs @@ -1,4 +1,5 @@ pub mod config; +pub mod constants; pub mod error; use std::cell::RefCell; @@ -14,14 +15,21 @@ thread_local! { } pub fn app_mut(cb: impl FnOnce(&mut App) -> error::Result) -> error::Result { - let res = APP.with(|app_cell| { - let mut app_borrow = app_cell.borrow_mut(); + // `try_with` so a `Drop` running after the TLS is destroyed sees an + // `AppAccess` error rather than panicking + let res = APP.try_with(|app_cell| { + let mut app_borrow = app_cell + .try_borrow_mut() + .map_err(|_| error::ProcessingError::AppAccess)?; let app = app_borrow .as_mut() .ok_or(error::ProcessingError::AppAccess)?; cb(app) - })?; - Ok(res) + }); + match res { + Ok(inner) => inner, + Err(_) => Err(error::ProcessingError::AppAccess), + } } pub fn is_already_init() -> error::Result { diff --git a/crates/processing_cuda/Cargo.toml b/crates/processing_cuda/Cargo.toml index 72896dcd..7ec477a9 100644 --- a/crates/processing_cuda/Cargo.toml +++ b/crates/processing_cuda/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_cuda" -version = "0.1.0" +version = "0.0.5" edition = "2024" [lints] diff --git a/crates/processing_ffi/Cargo.toml b/crates/processing_ffi/Cargo.toml index 2a996516..16d41364 100644 --- a/crates/processing_ffi/Cargo.toml +++ b/crates/processing_ffi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_ffi" -version = "0.0.1" +version = "0.0.5" edition = "2024" [lints] @@ -14,10 +14,13 @@ crate-type = ["cdylib", "staticlib"] default = [] wayland = ["processing/wayland"] x11 = ["processing/x11"] +webcam = ["processing/webcam"] +cuda = ["processing/cuda"] [dependencies] processing = { workspace = true } bevy = { workspace = true } +half = "2.7" [build-dependencies] cbindgen = "0.29" diff --git a/crates/processing_ffi/src/color.rs b/crates/processing_ffi/src/color.rs index cde66b52..ae0ea82a 100644 --- a/crates/processing_ffi/src/color.rs +++ b/crates/processing_ffi/src/color.rs @@ -30,4 +30,11 @@ impl Color { space: ColorSpace::Linear as u8, } } + + pub fn to_linear(self) -> LinearRgba { + ColorSpace::from_u8(self.space) + .unwrap_or(ColorSpace::Linear) + .color(self.c1, self.c2, self.c3, self.a) + .to_linear() + } } diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 7c76491d..731722cc 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -1,15 +1,21 @@ use bevy::{ - math::{Vec2, Vec3, Vec4}, + math::{Affine3A, Mat4, Vec2, Vec3, Vec4}, prelude::Entity, render::render_resource::{Extent3d, TextureFormat}, }; -use processing::prelude::{error::ProcessingError, *}; +use processing::prelude::{error::ProcessingError, shader_value::ShaderValue, *}; use crate::color::Color; mod color; mod error; +unsafe fn cstr_to_str<'a>(ptr: *const std::ffi::c_char) -> Result<&'a str, ProcessingError> { + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .map_err(|_| ProcessingError::InvalidArgument("non-UTF8 C string".to_string())) +} + /// Initialize libProcessing. /// /// SAFETY: @@ -37,7 +43,7 @@ pub extern "C" fn processing_surface_create( scale_factor: f32, ) -> u64 { error::clear_error(); - error::check(|| surface_create_macos(window_handle, width, height, scale_factor)) + error::check(|| surface_create_macos(window_handle, width, height, scale_factor, false)) .map(|e| e.to_bits()) .unwrap_or(0) } @@ -109,6 +115,30 @@ pub extern "C" fn processing_surface_create_x11( .unwrap_or(0) } +/// Create a WebGPU surface on Linux. The display server is auto-detected from +/// the environment. +/// +/// SAFETY: +/// - Init has been called. +/// - The handle types match the active display server. +/// - This is called from the same thread as init. +#[cfg(target_os = "linux")] +#[unsafe(no_mangle)] +pub extern "C" fn processing_surface_create( + window_handle: u64, + display_handle: u64, + width: u32, + height: u32, + scale_factor: f32, +) -> u64 { + error::clear_error(); + error::check(|| { + surface_create_linux(window_handle, display_handle, width, height, scale_factor) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + /// Create a graphics context for a surface. /// /// SAFETY: @@ -196,6 +226,23 @@ pub extern "C" fn processing_background_image(graphics_id: u64, image_id: u64) { }); } +/// Clear the graphics surface to transparent. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_clear(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::BackgroundColor(bevy::prelude::Color::NONE), + ) + }); +} + /// Begins the draw for the given graphics context. /// /// SAFETY: @@ -418,6 +465,60 @@ pub extern "C" fn processing_pop_matrix(graphics_id: u64) { error::check(|| graphics_record_command(graphics_entity, DrawCommand::PopMatrix)); } +/// Push the current style onto the style stack. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_push_style(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::PushStyle)); +} + +/// Pop the most recently saved style off the style stack. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_pop_style(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::PopStyle)); +} + +/// Push both the style and the transformation matrix onto their stacks. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_push(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command(graphics_entity, DrawCommand::PushStyle)?; + graphics_record_command(graphics_entity, DrawCommand::PushMatrix) + }); +} + +/// Pop both the style and the transformation matrix off their stacks. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_pop(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command(graphics_entity, DrawCommand::PopStyle)?; + graphics_record_command(graphics_entity, DrawCommand::PopMatrix) + }); +} + /// Reset the transformation matrix to identity. /// /// SAFETY: @@ -436,24 +537,32 @@ pub extern "C" fn processing_reset_matrix(graphics_id: u64) { /// - graphics_id is a valid ID returned from graphics_create. /// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_translate(graphics_id: u64, x: f32, y: f32) { +pub extern "C" fn processing_translate(graphics_id: u64, x: f32, y: f32, z: f32) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - graphics_record_command(graphics_entity, DrawCommand::Translate(Vec2::new(x, y))) + graphics_record_command(graphics_entity, DrawCommand::Translate(Vec3::new(x, y, z))) }); } -/// Rotate the coordinate system. +/// Rotate the coordinate system by `angle` about the axis (x, y, z). /// /// SAFETY: /// - graphics_id is a valid ID returned from graphics_create. /// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_rotate(graphics_id: u64, angle: f32) { +pub extern "C" fn processing_rotate(graphics_id: u64, angle: f32, x: f32, y: f32, z: f32) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_record_command(graphics_entity, DrawCommand::Rotate { angle })); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Rotate { + angle, + axis: Vec3::new(x, y, z), + }, + ) + }); } /// Scale the coordinate system. @@ -462,10 +571,12 @@ pub extern "C" fn processing_rotate(graphics_id: u64, angle: f32) { /// - graphics_id is a valid ID returned from graphics_create. /// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_scale(graphics_id: u64, x: f32, y: f32) { +pub extern "C" fn processing_scale(graphics_id: u64, x: f32, y: f32, z: f32) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_record_command(graphics_entity, DrawCommand::Scale(Vec2::new(x, y)))); + error::check(|| { + graphics_record_command(graphics_entity, DrawCommand::Scale(Vec3::new(x, y, z))) + }); } /// Shear along the X axis. @@ -977,851 +1088,2805 @@ pub extern "C" fn processing_end_contour(graphics_id: u64) { error::check(|| graphics_record_command(graphics_entity, DrawCommand::EndContour)); } -/// Create an image from raw pixel data. +// --- Font --- + +/// Load a font file and return a font entity ID. +/// Returns 0 on error. /// /// # Safety -/// - Init has been called. -/// - data is a valid pointer to data_len bytes of RGBA pixel data. -/// - This is called from the same thread as init. +/// - path_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_image_create( - width: u32, - height: u32, - data: *const u8, - data_len: usize, -) -> u64 { +pub unsafe extern "C" fn processing_load_font(path_ptr: *const std::ffi::c_char) -> u64 { error::clear_error(); - // SAFETY: Caller must ensure that `data` is valid for `data_len` bytes. - let data = unsafe { std::slice::from_raw_parts(data, data_len) }; - error::check(|| { - let size = Extent3d { - width, - height, - depth_or_array_layers: 1, - }; - image_create(size, data.to_vec(), TextureFormat::Rgba8UnormSrgb) - }) - .map(|entity| entity.to_bits()) - .unwrap_or(0) + let path = unsafe { std::ffi::CStr::from_ptr(path_ptr) }.to_string_lossy(); + error::check(|| font_load(&path).map(|e| e.to_bits())).unwrap_or(0) } -/// Load an image from a file path. +/// Create a font handle from an existing font family name. +/// Returns 0 on error. /// /// # Safety -/// - Init has been called. -/// - path is a valid null-terminated C string. -/// - This is called from the same thread as init. -/// -/// Note: This function is currently synchronous but Bevy's asset loading is async. -/// The image may not be immediately available. This needs to be improved. +/// - name_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_image_load(path: *const std::ffi::c_char) -> u64 { +pub unsafe extern "C" fn processing_create_font(name_ptr: *const std::ffi::c_char) -> u64 { error::clear_error(); - - // SAFETY: Caller guarantees path is a valid C string - let c_str = unsafe { std::ffi::CStr::from_ptr(path) }; - let path_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - error::set_error("Invalid UTF-8 in image path"); - return 0; - } - }; - - error::check(|| image_load(path_str)) - .map(|entity| entity.to_bits()) - .unwrap_or(0) + let name = unsafe { std::ffi::CStr::from_ptr(name_ptr) }.to_string_lossy(); + error::check(|| font_create(&name).map(|e| e.to_bits())).unwrap_or(0) } +/// Query the number of variable font axes for a font. +/// Returns 0 if the font is not variable or not found. #[unsafe(no_mangle)] -pub extern "C" fn processing_image_resize(image_id: u64, new_width: u32, new_height: u32) { +pub extern "C" fn processing_font_variation_count(font_id: u64) -> u32 { error::clear_error(); - let image_entity = Entity::from_bits(image_id); - let new_size = Extent3d { - width: new_width, - height: new_height, - depth_or_array_layers: 1, - }; - error::check(|| image_resize(image_entity, new_size)); + let font_entity = Entity::from_bits(font_id); + error::check(|| font_variations(font_entity).map(|v| v.len() as u32)).unwrap_or(0) } -/// Load pixels from an image into a caller-provided buffer. +/// Query variable font axis info. +/// Writes tag (4 bytes), min, max, default to out buffer at the given index. /// /// # Safety -/// - Init and image_create have been called. -/// - image_id is a valid ID returned from image_create. -/// - buffer is a valid pointer to at least buffer_len Color elements. -/// - buffer_len must equal width * height of the image. -/// - This is called from the same thread as init. +/// - out_tag is a valid pointer to at least 4 writable bytes. +/// - out_min, out_max, out_default are valid pointers to writable f32 values. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_image_readback( - image_id: u64, - buffer: *mut Color, - buffer_len: usize, -) { - error::clear_error(); - let image_entity = Entity::from_bits(image_id); - error::check(|| { - let colors = image_readback(image_entity)?; - - // Validate buffer size - if colors.len() != buffer_len { - let error_msg = format!( - "Buffer size mismatch: expected {}, got {}", - colors.len(), - buffer_len - ); - error::set_error(&error_msg); - return Err(error::ProcessingError::InvalidArgument(error_msg)); - } - - // SAFETY: Caller guarantees buffer is valid for buffer_len elements +pub unsafe extern "C" fn processing_font_variation( + font_id: u64, + index: u32, + out_tag: *mut u8, + out_min: *mut f32, + out_max: *mut f32, + out_default: *mut f32, +) -> bool { + error::clear_error(); + let font_entity = Entity::from_bits(font_id); + let axes = error::check(|| font_variations(font_entity)); + if let Some(axes) = axes + && let Some(axis) = axes.get(index as usize) + { + let tag_bytes = axis.tag.as_bytes(); + let len = tag_bytes.len().min(4); unsafe { - let buffer_slice = std::slice::from_raw_parts_mut(buffer, buffer_len); - for (i, color) in colors.iter().enumerate() { - buffer_slice[i] = Color::from_linear(*color); + std::ptr::copy_nonoverlapping(tag_bytes.as_ptr(), out_tag, len); + for i in len..4 { + *out_tag.add(i) = b' '; } + *out_min = axis.min; + *out_max = axis.max; + *out_default = axis.default; } - - Ok(()) - }); + return true; + } + false } +/// Set the current text font. +/// Pass 0 to reset to the default font. #[unsafe(no_mangle)] -pub extern "C" fn processing_mode_3d(graphics_id: u64) { +pub extern "C" fn processing_text_font(graphics_id: u64, font_id: u64) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_mode_3d(graphics_entity)); + let font_entity = if font_id == 0 { + None + } else { + Some(Entity::from_bits(font_id)) + }; + error::check(|| graphics_text_font(graphics_entity, font_entity)); } -#[unsafe(no_mangle)] -pub extern "C" fn processing_mode_2d(graphics_id: u64) { - error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_mode_2d(graphics_entity)); -} +// --- Text --- +/// Draw text at a position. +/// +/// # Safety +/// - str_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub extern "C" fn processing_perspective( +pub unsafe extern "C" fn processing_text( graphics_id: u64, - fov: f32, - aspect: f32, - near: f32, - far: f32, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, ) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); error::check(|| { - graphics_perspective( + graphics_record_command( graphics_entity, - fov, - aspect, - near, - far, - bevy::math::Vec4::new(0.0, 0.0, -1.0, -near), + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, ) }); } +/// Draw text at a 3D position. +/// +/// # Safety +/// - str_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub extern "C" fn processing_ortho( +pub unsafe extern "C" fn processing_text_3d( graphics_id: u64, - left: f32, - right: f32, - bottom: f32, - top: f32, - near: f32, - far: f32, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, ) { error::clear_error(); let graphics_entity = Entity::from_bits(graphics_id); - error::check(|| graphics_ortho(graphics_entity, left, right, bottom, top, near, far)); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z, + max_w: None, + max_h: None, + }, + ) + }); } +/// Draw an integer as text at a position. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_set_position(entity_id: u64, x: f32, y: f32, z: f32) { +pub extern "C" fn processing_text_int(graphics_id: u64, value: i32, x: f32, y: f32) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_set_position(entity, Vec3::new(x, y, z))); + let graphics_entity = Entity::from_bits(graphics_id); + let content = value.to_string(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, + ) + }); } +/// Draw a float as text at a position (formatted to 3 decimal places). #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_translate(entity_id: u64, x: f32, y: f32, z: f32) { +pub extern "C" fn processing_text_float(graphics_id: u64, value: f32, x: f32, y: f32) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_translate(entity, Vec3::new(x, y, z))); + let graphics_entity = Entity::from_bits(graphics_id); + let content = format!("{:.3}", value); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: None, + max_h: None, + }, + ) + }); } +/// Draw text within a bounding box (with word wrapping). +/// +/// # Safety +/// - str_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_set_rotation(entity_id: u64, x: f32, y: f32, z: f32) { +pub unsafe extern "C" fn processing_text_box( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + w: f32, + h: f32, +) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_set_rotation(entity, Vec3::new(x, y, z))); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) } + .to_string_lossy() + .into_owned(); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Text { + content, + x, + y, + z: 0.0, + max_w: Some(w), + max_h: Some(h), + }, + ) + }); } +/// Set the text style. 0=NORMAL, 1=ITALIC, 2=BOLD, 3=BOLDITALIC #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_rotate_x(entity_id: u64, angle: f32) { +pub extern "C" fn processing_text_style(graphics_id: u64, style: u8) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_rotate_x(entity, angle)); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_style(graphics_entity, style)); } +/// Compute the bounding box of text. Writes [x, y, w, h] to out_bounds. +/// +/// # Safety +/// - str_ptr is a valid pointer to a null-terminated string. +/// - out_bounds is a valid pointer to a writable float array of at least 4 elements. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_rotate_y(entity_id: u64, angle: f32) { +pub unsafe extern "C" fn processing_text_bounds( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, + x: f32, + y: f32, + out_bounds: *mut f32, +) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_rotate_y(entity, angle)); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) }.to_string_lossy(); + if let Some(bounds) = + error::check(|| graphics_text_bounds(graphics_entity, &content, x, y, None, None)) + { + unsafe { + *out_bounds = bounds[0]; + *out_bounds.add(1) = bounds[1]; + *out_bounds.add(2) = bounds[2]; + *out_bounds.add(3) = bounds[3]; + } + } } +/// Set a font variation axis value (e.g. "wdth", 75.0). +/// +/// # Safety +/// - tag_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_rotate_z(entity_id: u64, angle: f32) { +pub unsafe extern "C" fn processing_text_variation( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, + value: f32, +) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_rotate_z(entity, angle)); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_text_variation(graphics_entity, &tag, value)); } +/// Clear all font variation axis overrides. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_rotate_axis( - entity_id: u64, - angle: f32, - axis_x: f32, - axis_y: f32, - axis_z: f32, +pub extern "C" fn processing_clear_text_variations(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_clear_text_variations(graphics_entity)); +} + +/// Enable/configure an OpenType font feature (e.g. "smcp", 1). +/// +/// # Safety +/// - tag_ptr is a valid pointer to a null-terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_feature( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, + value: u16, ) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_rotate_axis(entity, angle, Vec3::new(axis_x, axis_y, axis_z))); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_text_feature(graphics_entity, &tag, value)); } +/// Disable an OpenType font feature. +/// +/// # Safety +/// - tag_ptr is a valid pointer to a null-terminated string. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_set_scale(entity_id: u64, x: f32, y: f32, z: f32) { +pub unsafe extern "C" fn processing_no_text_feature( + graphics_id: u64, + tag_ptr: *const std::ffi::c_char, +) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_set_scale(entity, Vec3::new(x, y, z))); + let graphics_entity = Entity::from_bits(graphics_id); + let tag = unsafe { std::ffi::CStr::from_ptr(tag_ptr) }.to_string_lossy(); + error::check(|| graphics_no_text_feature(graphics_entity, &tag)); } +/// Clear all OpenType font feature overrides. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_scale(entity_id: u64, x: f32, y: f32, z: f32) { +pub extern "C" fn processing_clear_text_features(graphics_id: u64) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_scale(entity, Vec3::new(x, y, z))); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_clear_text_features(graphics_entity)); } +/// Set per-glyph colors for the next text() call. +/// colors_ptr points to an array of (r, g, b, a) float tuples. +/// +/// # Safety +/// - colors_ptr is a valid pointer to count * 4 readable floats. #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_look_at( - entity_id: u64, - target_x: f32, - target_y: f32, - target_z: f32, +pub unsafe extern "C" fn processing_text_glyph_colors( + graphics_id: u64, + colors_ptr: *const f32, + count: u32, ) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_look_at(entity, Vec3::new(target_x, target_y, target_z))); + let graphics_entity = Entity::from_bits(graphics_id); + let colors: Vec = (0..count as usize) + .map(|i| unsafe { + let base = colors_ptr.add(i * 4); + bevy::color::Color::srgba(*base, *base.add(1), *base.add(2), *base.add(3)) + }) + .collect(); + error::check(|| graphics_text_glyph_colors(graphics_entity, colors)); } +/// Set the font weight for variable fonts (e.g. 100-900). #[unsafe(no_mangle)] -pub extern "C" fn processing_transform_reset(entity_id: u64) { +pub extern "C" fn processing_text_weight(graphics_id: u64, weight: f32) { error::clear_error(); - let entity = Entity::from_bits(entity_id); - error::check(|| transform_reset(entity)); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_weight(graphics_entity, weight)); } -pub const PROCESSING_ATTR_FORMAT_FLOAT: u8 = 1; -pub const PROCESSING_ATTR_FORMAT_FLOAT2: u8 = 2; -pub const PROCESSING_ATTR_FORMAT_FLOAT3: u8 = 3; -pub const PROCESSING_ATTR_FORMAT_FLOAT4: u8 = 4; +/// Set the text size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_size(graphics_id: u64, size: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::TextSize(size))); +} -pub const PROCESSING_TOPOLOGY_POINT_LIST: u8 = 0; -pub const PROCESSING_TOPOLOGY_LINE_LIST: u8 = 1; -pub const PROCESSING_TOPOLOGY_LINE_STRIP: u8 = 2; -pub const PROCESSING_TOPOLOGY_TRIANGLE_LIST: u8 = 3; -pub const PROCESSING_TOPOLOGY_TRIANGLE_STRIP: u8 = 4; +/// Set the text alignment. +/// h: 0=LEFT, 1=CENTER, 2=RIGHT +/// v: 0=BASELINE, 1=TOP, 2=CENTER, 3=BOTTOM +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_align(graphics_id: u64, h: u8, v: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_align(graphics_entity, h, v)); +} -pub const PROCESSING_STROKE_CAP_ROUND: u8 = 0; -pub const PROCESSING_STROKE_CAP_SQUARE: u8 = 1; -pub const PROCESSING_STROKE_CAP_PROJECT: u8 = 2; +/// Set the text leading (line spacing). +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_leading(graphics_id: u64, leading: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::TextLeading(leading))); +} -pub const PROCESSING_STROKE_JOIN_ROUND: u8 = 0; -pub const PROCESSING_STROKE_JOIN_MITER: u8 = 1; -pub const PROCESSING_STROKE_JOIN_BEVEL: u8 = 2; +/// Set the text wrap mode. 0=WORD, 1=CHAR +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_wrap(graphics_id: u64, mode: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_wrap(graphics_entity, mode)); +} -pub const PROCESSING_BLEND_MODE_BLEND: u8 = 0; -pub const PROCESSING_BLEND_MODE_ADD: u8 = 1; -pub const PROCESSING_BLEND_MODE_SUBTRACT: u8 = 2; -pub const PROCESSING_BLEND_MODE_DARKEST: u8 = 3; -pub const PROCESSING_BLEND_MODE_LIGHTEST: u8 = 4; -pub const PROCESSING_BLEND_MODE_DIFFERENCE: u8 = 5; -pub const PROCESSING_BLEND_MODE_EXCLUSION: u8 = 6; -pub const PROCESSING_BLEND_MODE_MULTIPLY: u8 = 7; -pub const PROCESSING_BLEND_MODE_SCREEN: u8 = 8; -pub const PROCESSING_BLEND_MODE_REPLACE: u8 = 9; +/// Measure the width of text. +/// +/// # Safety +/// - str_ptr is a valid pointer to a null-terminated string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_text_width( + graphics_id: u64, + str_ptr: *const std::ffi::c_char, +) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let content = unsafe { std::ffi::CStr::from_ptr(str_ptr) }.to_string_lossy(); + error::check(|| graphics_text_width(graphics_entity, &content)).unwrap_or(0.0) +} -pub const PROCESSING_BLEND_FACTOR_ZERO: u8 = 0; -pub const PROCESSING_BLEND_FACTOR_ONE: u8 = 1; -pub const PROCESSING_BLEND_FACTOR_SRC: u8 = 2; -pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_SRC: u8 = 3; -pub const PROCESSING_BLEND_FACTOR_SRC_ALPHA: u8 = 4; -pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA: u8 = 5; -pub const PROCESSING_BLEND_FACTOR_DST: u8 = 6; -pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_DST: u8 = 7; -pub const PROCESSING_BLEND_FACTOR_DST_ALPHA: u8 = 8; -pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_DST_ALPHA: u8 = 9; -pub const PROCESSING_BLEND_FACTOR_SRC_ALPHA_SATURATED: u8 = 10; +/// Get the text ascent for the current font size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_ascent(graphics_id: u64) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_ascent(graphics_entity)).unwrap_or(0.0) +} -pub const PROCESSING_BLEND_OP_ADD: u8 = 0; -pub const PROCESSING_BLEND_OP_SUBTRACT: u8 = 1; -pub const PROCESSING_BLEND_OP_REVERSE_SUBTRACT: u8 = 2; -pub const PROCESSING_BLEND_OP_MIN: u8 = 3; -pub const PROCESSING_BLEND_OP_MAX: u8 = 4; +/// Get the text descent for the current font size. +#[unsafe(no_mangle)] +pub extern "C" fn processing_text_descent(graphics_id: u64) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_text_descent(graphics_entity)).unwrap_or(0.0) +} +/// Create an image from raw pixel data. +/// +/// # Safety +/// - Init has been called. +/// - data is a valid pointer to data_len bytes of RGBA pixel data. +/// - This is called from the same thread as init. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_create() -> u64 { +pub unsafe extern "C" fn processing_image_create( + width: u32, + height: u32, + data: *const u8, + data_len: usize, +) -> u64 { error::clear_error(); - error::check(geometry_layout_create) - .map(|e| e.to_bits()) + // SAFETY: Caller must ensure that `data` is valid for `data_len` bytes. + let data = unsafe { std::slice::from_raw_parts(data, data_len) }; + error::check(|| { + let size = Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + image_create(size, data.to_vec(), TextureFormat::Rgba8UnormSrgb) + }) + .map(|entity| entity.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `init` has been called. +/// - `floats` is valid for `floats_len` f32 reads. +/// - Called from the same thread as `init`. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_image_create_hdr( + width: u32, + height: u32, + floats: *const f32, + floats_len: usize, +) -> u64 { + error::clear_error(); + let src = unsafe { std::slice::from_raw_parts(floats, floats_len) }; + error::check(|| { + let mut packed = Vec::with_capacity(src.len() * 2); + for &f in src { + packed.extend_from_slice(&half::f16::from_f32(f).to_le_bytes()); + } + let size = Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + image_create(size, packed, TextureFormat::Rgba16Float) + }) + .map(|entity| entity.to_bits()) + .unwrap_or(0) +} + +/// Load an image from a file path. +/// +/// # Safety +/// - Init has been called. +/// - path is a valid null-terminated C string. +/// - This is called from the same thread as init. +/// +/// Note: This function is currently synchronous but Bevy's asset loading is async. +/// The image may not be immediately available. This needs to be improved. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_image_load(path: *const std::ffi::c_char) -> u64 { + error::clear_error(); + + // SAFETY: Caller guarantees path is a valid C string + let c_str = unsafe { std::ffi::CStr::from_ptr(path) }; + let path_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + error::set_error("Invalid UTF-8 in image path"); + return 0; + } + }; + + error::check(|| image_load(path_str)) + .map(|entity| entity.to_bits()) .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_add_position(layout_id: u64) { +pub extern "C" fn processing_image_resize(image_id: u64, new_width: u32, new_height: u32) { + error::clear_error(); + let image_entity = Entity::from_bits(image_id); + let new_size = Extent3d { + width: new_width, + height: new_height, + depth_or_array_layers: 1, + }; + error::check(|| image_resize(image_entity, new_size)); +} + +/// Load pixels from an image into a caller-provided buffer. +/// +/// # Safety +/// - Init and image_create have been called. +/// - image_id is a valid ID returned from image_create. +/// - buffer is a valid pointer to at least buffer_len Color elements. +/// - buffer_len must equal width * height of the image. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_image_readback( + image_id: u64, + buffer: *mut Color, + buffer_len: usize, +) { + error::clear_error(); + let image_entity = Entity::from_bits(image_id); + error::check(|| { + let colors = image_readback(image_entity)?; + + // Validate buffer size + if colors.len() != buffer_len { + let error_msg = format!( + "Buffer size mismatch: expected {}, got {}", + colors.len(), + buffer_len + ); + error::set_error(&error_msg); + return Err(error::ProcessingError::InvalidArgument(error_msg)); + } + + // SAFETY: Caller guarantees buffer is valid for buffer_len elements + unsafe { + let buffer_slice = std::slice::from_raw_parts_mut(buffer, buffer_len); + for (i, color) in colors.iter().enumerate() { + buffer_slice[i] = Color::from_linear(*color); + } + } + + Ok(()) + }); +} + +/// Load pixels from the graphics surface into a caller-provided buffer. +/// +/// # Safety +/// - Init and graphics_create have been called. +/// - graphics_id is a valid ID returned from graphics_create. +/// - buffer is a valid pointer to at least buffer_len Color elements. +/// - buffer_len must equal width * height of the graphics surface. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_readback( + graphics_id: u64, + buffer: *mut Color, + buffer_len: usize, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let colors = graphics_readback(graphics_entity)?; + + if colors.len() != buffer_len { + let error_msg = format!( + "Buffer size mismatch: expected {}, got {}", + colors.len(), + buffer_len + ); + error::set_error(&error_msg); + return Err(error::ProcessingError::InvalidArgument(error_msg)); + } + + // SAFETY: Caller guarantees buffer is valid for buffer_len elements + unsafe { + let buffer_slice = std::slice::from_raw_parts_mut(buffer, buffer_len); + for (i, color) in colors.iter().enumerate() { + buffer_slice[i] = Color::from_linear(*color); + } + } + + Ok(()) + }); +} + +/// Write a caller-provided pixel buffer back onto the graphics surface. +/// +/// # Safety +/// - Init and graphics_create have been called. +/// - graphics_id is a valid ID returned from graphics_create. +/// - buffer is a valid pointer to at least buffer_len Color elements. +/// - buffer_len must equal width * height of the graphics surface. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_update( + graphics_id: u64, + buffer: *const Color, + buffer_len: usize, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + // SAFETY: Caller guarantees buffer is valid for buffer_len elements + let pixels: Vec<_> = unsafe { std::slice::from_raw_parts(buffer, buffer_len) } + .iter() + .map(|color| color.to_linear()) + .collect(); + graphics_update(graphics_entity, &pixels) + }); +} + +/// Write a caller-provided pixel buffer onto a rectangular region of the surface. +/// +/// # Safety +/// - Init and graphics_create have been called. +/// - graphics_id is a valid ID returned from graphics_create. +/// - buffer is a valid pointer to at least buffer_len Color elements. +/// - buffer_len must equal width * height. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_update_region( + graphics_id: u64, + x: u32, + y: u32, + width: u32, + height: u32, + buffer: *const Color, + buffer_len: usize, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + // SAFETY: Caller guarantees buffer is valid for buffer_len elements + let pixels: Vec<_> = unsafe { std::slice::from_raw_parts(buffer, buffer_len) } + .iter() + .map(|color| color.to_linear()) + .collect(); + graphics_update_region(graphics_entity, x, y, width, height, &pixels) + }); +} + +/// Set a single pixel on the graphics surface. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_set(graphics_id: u64, x: u32, y: u32, color: Color) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_update_region(graphics_entity, x, y, 1, 1, &[color.to_linear()])); +} + +/// Set the tint color applied to images. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_tint(graphics_id: u64, color: Color) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + graphics_record_command(graphics_entity, DrawCommand::Tint(color.resolve(&mode))) + }); +} + +/// Remove the image tint. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_no_tint(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::NoTint)); +} + +/// Set how image() interprets its coordinates (CORNER/CORNERS/CENTER/RADIUS). +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_image_mode(graphics_id: u64, mode: u8) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::ImageMode(processing::prelude::ShapeMode::from(mode)), + ) + }); +} + +/// Draw an image at (dx, dy) at its native size. +/// +/// SAFETY: +/// - graphics_id and image_id are valid IDs from graphics_create/image_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_image(graphics_id: u64, image_id: u64, dx: f32, dy: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let image_entity = Entity::from_bits(image_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Image { + entity: image_entity, + dx, + dy, + d_width: None, + d_height: None, + sx: None, + sy: None, + s_width: None, + s_height: None, + }, + ) + }); +} + +/// Draw an image at (dx, dy) scaled to (d_width, d_height). +/// +/// SAFETY: +/// - graphics_id and image_id are valid IDs from graphics_create/image_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_image_scaled( + graphics_id: u64, + image_id: u64, + dx: f32, + dy: f32, + d_width: f32, + d_height: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let image_entity = Entity::from_bits(image_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Image { + entity: image_entity, + dx, + dy, + d_width: Some(d_width), + d_height: Some(d_height), + sx: None, + sy: None, + s_width: None, + s_height: None, + }, + ) + }); +} + +/// Draw the (sx, sy, s_width, s_height) source region of an image into the +/// (dx, dy, d_width, d_height) destination rectangle. +/// +/// SAFETY: +/// - graphics_id and image_id are valid IDs from graphics_create/image_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_image_region( + graphics_id: u64, + image_id: u64, + dx: f32, + dy: f32, + d_width: f32, + d_height: f32, + sx: f32, + sy: f32, + s_width: f32, + s_height: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let image_entity = Entity::from_bits(image_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Image { + entity: image_entity, + dx, + dy, + d_width: Some(d_width), + d_height: Some(d_height), + sx: Some(sx), + sy: Some(sy), + s_width: Some(s_width), + s_height: Some(s_height), + }, + ) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_mode_3d(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_mode_3d(graphics_entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_mode_2d(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_mode_2d(graphics_entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_perspective( + graphics_id: u64, + fov: f32, + aspect: f32, + near: f32, + far: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_perspective( + graphics_entity, + fov, + aspect, + near, + far, + bevy::math::Vec4::new(0.0, 0.0, -1.0, -near), + ) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_ortho( + graphics_id: u64, + left: f32, + right: f32, + bottom: f32, + top: f32, + near: f32, + far: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_ortho(graphics_entity, left, right, bottom, top, near, far)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_set_position(entity_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_set_position(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_translate(entity_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_translate(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_set_rotation(entity_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_set_rotation(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_rotate_x(entity_id: u64, angle: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_rotate_x(entity, angle)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_rotate_y(entity_id: u64, angle: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_rotate_y(entity, angle)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_rotate_z(entity_id: u64, angle: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_rotate_z(entity, angle)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_rotate_axis( + entity_id: u64, + angle: f32, + axis_x: f32, + axis_y: f32, + axis_z: f32, +) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_rotate_axis(entity, angle, Vec3::new(axis_x, axis_y, axis_z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_set_scale(entity_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_set_scale(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_scale(entity_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_scale(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_look_at( + entity_id: u64, + target_x: f32, + target_y: f32, + target_z: f32, +) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_look_at(entity, Vec3::new(target_x, target_y, target_z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_transform_reset(entity_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(entity_id); + error::check(|| transform_reset(entity)); +} + +/// Attach an orbit camera controller. +#[unsafe(no_mangle)] +pub extern "C" fn processing_orbit_camera(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_orbit_camera(graphics_entity)); +} + +/// Attach a free-flight camera controller. +#[unsafe(no_mangle)] +pub extern "C" fn processing_free_camera(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_free_camera(graphics_entity)); +} + +/// Attach a pan/zoom camera controller. +#[unsafe(no_mangle)] +pub extern "C" fn processing_pan_camera(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_pan_camera(graphics_entity)); +} + +/// Remove the active camera controller. +#[unsafe(no_mangle)] +pub extern "C" fn processing_disable_camera_controller(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_disable_camera_controller(graphics_entity)); +} + +/// Set the camera distance from its center (zoom). +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_set_distance(graphics_id: u64, distance: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_set_distance(graphics_entity, distance)); +} + +/// Set the orbit camera's look-at center. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_set_center(graphics_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_set_center(graphics_entity, Vec3::new(x, y, z))); +} + +/// Set the minimum camera distance. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_set_min_distance(graphics_id: u64, min: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_set_min_distance(graphics_entity, min)); +} + +/// Set the maximum camera distance. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_set_max_distance(graphics_id: u64, max: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_set_max_distance(graphics_entity, max)); +} + +/// Set the camera controller's sensitivity. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_set_speed(graphics_id: u64, speed: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_set_speed(graphics_entity, speed)); +} + +/// Reset the camera controller to its initial pose. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera_reset(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| camera_reset(graphics_entity)); +} + +/// Position the camera at eye, looking at center with the given up. +/// +/// An active camera controller overrides this each frame. +#[unsafe(no_mangle)] +pub extern "C" fn processing_camera( + graphics_id: u64, + eye_x: f32, + eye_y: f32, + eye_z: f32, + center_x: f32, + center_y: f32, + center_z: f32, + up_x: f32, + up_y: f32, + up_z: f32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_camera( + graphics_entity, + Vec3::new(eye_x, eye_y, eye_z), + Vec3::new(center_x, center_y, center_z), + Vec3::new(up_x, up_y, up_z), + ) + }); +} + +/// A column-major 4x4 matrix. +#[repr(C)] +pub struct Matrix { + pub m: [f32; 16], +} + +/// Right-multiply the model matrix by a column-major 4x4 matrix. +/// +/// # Safety +/// - matrix points to at least 16 f32. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_apply_matrix(graphics_id: u64, matrix: *const f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + // SAFETY: Caller guarantees matrix points to 16 valid f32 elements + let cols: [f32; 16] = unsafe { std::slice::from_raw_parts(matrix, 16) } + .try_into() + .unwrap(); + let affine = Affine3A::from_mat4(Mat4::from_cols_array(&cols)); + graphics_record_command(graphics_entity, DrawCommand::ApplyMatrix(affine)) + }); +} + +/// The current model matrix, column-major. Flushes pending draws; identity on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_get_matrix(graphics_id: u64) -> Matrix { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let m = error::check(|| graphics_get_matrix(graphics_entity).map(|mat| mat.to_cols_array())) + .unwrap_or_else(|| Mat4::IDENTITY.to_cols_array()); + Matrix { m } +} + +/// Model-space point to world-space X (modelX). +#[unsafe(no_mangle)] +pub extern "C" fn processing_model_x(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_model_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.x)) + .unwrap_or(0.0) +} + +/// Model-space point to world-space Y (modelY). +#[unsafe(no_mangle)] +pub extern "C" fn processing_model_y(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_model_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.y)) + .unwrap_or(0.0) +} + +/// Model-space point to world-space Z (modelZ). +#[unsafe(no_mangle)] +pub extern "C" fn processing_model_z(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_model_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.z)) + .unwrap_or(0.0) +} + +/// Model-space point to screen X in pixels (screenX). +#[unsafe(no_mangle)] +pub extern "C" fn processing_screen_x(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_screen_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.x)) + .unwrap_or(0.0) +} + +/// Model-space point to screen Y in pixels (screenY). +#[unsafe(no_mangle)] +pub extern "C" fn processing_screen_y(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_screen_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.y)) + .unwrap_or(0.0) +} + +/// Model-space point to screen depth in [0,1] (screenZ). +#[unsafe(no_mangle)] +pub extern "C" fn processing_screen_z(graphics_id: u64, x: f32, y: f32, z: f32) -> f32 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_screen_point(graphics_entity, Vec3::new(x, y, z)).map(|p| p.z)) + .unwrap_or(0.0) +} + +pub const PROCESSING_ATTR_FORMAT_FLOAT: u8 = 1; +pub const PROCESSING_ATTR_FORMAT_FLOAT2: u8 = 2; +pub const PROCESSING_ATTR_FORMAT_FLOAT3: u8 = 3; +pub const PROCESSING_ATTR_FORMAT_FLOAT4: u8 = 4; + +pub const PROCESSING_TOPOLOGY_POINT_LIST: u8 = 0; +pub const PROCESSING_TOPOLOGY_LINE_LIST: u8 = 1; +pub const PROCESSING_TOPOLOGY_LINE_STRIP: u8 = 2; +pub const PROCESSING_TOPOLOGY_TRIANGLE_LIST: u8 = 3; +pub const PROCESSING_TOPOLOGY_TRIANGLE_STRIP: u8 = 4; + +pub const PROCESSING_STROKE_CAP_ROUND: u8 = 0; +pub const PROCESSING_STROKE_CAP_SQUARE: u8 = 1; +pub const PROCESSING_STROKE_CAP_PROJECT: u8 = 2; + +pub const PROCESSING_STROKE_JOIN_ROUND: u8 = 0; +pub const PROCESSING_STROKE_JOIN_MITER: u8 = 1; +pub const PROCESSING_STROKE_JOIN_BEVEL: u8 = 2; + +pub const PROCESSING_BLEND_MODE_BLEND: u8 = 0; +pub const PROCESSING_BLEND_MODE_ADD: u8 = 1; +pub const PROCESSING_BLEND_MODE_SUBTRACT: u8 = 2; +pub const PROCESSING_BLEND_MODE_DARKEST: u8 = 3; +pub const PROCESSING_BLEND_MODE_LIGHTEST: u8 = 4; +pub const PROCESSING_BLEND_MODE_DIFFERENCE: u8 = 5; +pub const PROCESSING_BLEND_MODE_EXCLUSION: u8 = 6; +pub const PROCESSING_BLEND_MODE_MULTIPLY: u8 = 7; +pub const PROCESSING_BLEND_MODE_SCREEN: u8 = 8; +pub const PROCESSING_BLEND_MODE_REPLACE: u8 = 9; + +pub const PROCESSING_BLEND_FACTOR_ZERO: u8 = 0; +pub const PROCESSING_BLEND_FACTOR_ONE: u8 = 1; +pub const PROCESSING_BLEND_FACTOR_SRC: u8 = 2; +pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_SRC: u8 = 3; +pub const PROCESSING_BLEND_FACTOR_SRC_ALPHA: u8 = 4; +pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_SRC_ALPHA: u8 = 5; +pub const PROCESSING_BLEND_FACTOR_DST: u8 = 6; +pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_DST: u8 = 7; +pub const PROCESSING_BLEND_FACTOR_DST_ALPHA: u8 = 8; +pub const PROCESSING_BLEND_FACTOR_ONE_MINUS_DST_ALPHA: u8 = 9; +pub const PROCESSING_BLEND_FACTOR_SRC_ALPHA_SATURATED: u8 = 10; + +pub const PROCESSING_BLEND_OP_ADD: u8 = 0; +pub const PROCESSING_BLEND_OP_SUBTRACT: u8 = 1; +pub const PROCESSING_BLEND_OP_REVERSE_SUBTRACT: u8 = 2; +pub const PROCESSING_BLEND_OP_MIN: u8 = 3; +pub const PROCESSING_BLEND_OP_MAX: u8 = 4; + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_create() -> u64 { + error::clear_error(); + error::check(geometry_layout_create) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_add_position(layout_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_layout_add_position(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_add_normal(layout_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_layout_add_normal(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_add_color(layout_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_layout_add_color(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_add_uv(layout_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_layout_add_uv(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_add_attribute(layout_id: u64, attr_id: u64) { + error::clear_error(); + let layout_entity = Entity::from_bits(layout_id); + let attr_entity = Entity::from_bits(attr_id); + error::check(|| geometry_layout_add_attribute(layout_entity, attr_entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_layout_destroy(layout_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_layout_destroy(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_create_with_layout(layout_id: u64, topology: u8) -> u64 { + error::clear_error(); + let Some(topo) = geometry::Topology::from_u8(topology) else { + error::set_error("Invalid topology"); + return 0; + }; + let entity = Entity::from_bits(layout_id); + error::check(|| geometry_create_with_layout(entity, topo)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_create(topology: u8) -> u64 { + error::clear_error(); + let Some(topo) = geometry::Topology::from_u8(topology) else { + error::set_error("Invalid topology"); + return 0; + }; + error::check(|| geometry_create(topo)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_normal(geo_id: u64, nx: f32, ny: f32, nz: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_normal(entity, Vec3::new(nx, ny, nz))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_color(geo_id: u64, r: f32, g: f32, b: f32, a: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_color(entity, Vec4::new(r, g, b, a))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_uv(geo_id: u64, u: f32, v: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_uv(entity, u, v)); +} + +/// # Safety +/// - `name` must be a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_attribute_create( + name: *const std::ffi::c_char, + format: u8, +) -> u64 { + error::clear_error(); + + let c_str = unsafe { std::ffi::CStr::from_ptr(name) }; + let name_str = match c_str.to_str() { + Ok(s) => s, + Err(_) => { + error::set_error("Invalid UTF-8 in attribute name"); + return 0; + } + }; + + let attr_format = match geometry::AttributeFormat::from_u8(format) { + Some(f) => f, + None => { + error::set_error("Invalid attribute format"); + return 0; + } + }; + + error::check(|| geometry_attribute_create(name_str, attr_format)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_destroy(attr_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(attr_id); + error::check(|| geometry_attribute_destroy(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_position() -> u64 { + geometry_attribute_position().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_normal() -> u64 { + geometry_attribute_normal().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_color() -> u64 { + geometry_attribute_color().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_uv() -> u64 { + geometry_attribute_uv().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_rotation() -> u64 { + geometry_attribute_rotation().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_scale() -> u64 { + geometry_attribute_scale().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_life() -> u64 { + geometry_attribute_life().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_velocity() -> u64 { + geometry_attribute_velocity().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_age() -> u64 { + geometry_attribute_age().to_bits() +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_format(attr_id: u64) -> u8 { + error::clear_error(); + error::check(|| { + let (_name, fmt) = geometry_attribute_info(Entity::from_bits(attr_id))?; + Ok(match fmt { + geometry::AttributeFormat::Float => 1, + geometry::AttributeFormat::Float2 => 2, + geometry::AttributeFormat::Float3 => 3, + geometry::AttributeFormat::Float4 => 4, + }) + }) + .unwrap_or(0) +} + +/// # Safety +/// - `out` is valid for `out_cap` byte writes (may be null when `out_cap == 0` +/// for a length query). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_attribute_name( + attr_id: u64, + out: *mut u8, + out_cap: u64, +) -> u64 { + error::clear_error(); + let Some((name, _)) = error::check(|| geometry_attribute_info(Entity::from_bits(attr_id))) + else { + return 0; + }; + let name_bytes = name.as_bytes(); + let name_len = name_bytes.len(); + if out_cap > 0 && !out.is_null() { + let copy_len = name_len.min((out_cap - 1) as usize); + unsafe { std::ptr::copy_nonoverlapping(name_bytes.as_ptr(), out, copy_len) }; + unsafe { *out.add(copy_len) = 0 }; + } + name_len as u64 +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_float(geo_id: u64, attr_id: u64, v: f32) { + error::clear_error(); + let geo_entity = Entity::from_bits(geo_id); + let attr_entity = Entity::from_bits(attr_id); + error::check(|| geometry_attribute_float(geo_entity, attr_entity, v)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_float2(geo_id: u64, attr_id: u64, x: f32, y: f32) { + error::clear_error(); + let geo_entity = Entity::from_bits(geo_id); + let attr_entity = Entity::from_bits(attr_id); + error::check(|| geometry_attribute_float2(geo_entity, attr_entity, x, y)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_float3( + geo_id: u64, + attr_id: u64, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + let geo_entity = Entity::from_bits(geo_id); + let attr_entity = Entity::from_bits(attr_id); + error::check(|| geometry_attribute_float3(geo_entity, attr_entity, x, y, z)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_attribute_float4( + geo_id: u64, + attr_id: u64, + x: f32, + y: f32, + z: f32, + w: f32, +) { + error::clear_error(); + let geo_entity = Entity::from_bits(geo_id); + let attr_entity = Entity::from_bits(attr_id); + error::check(|| geometry_attribute_float4(geo_entity, attr_entity, x, y, z, w)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_vertex(geo_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_vertex(entity, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_index(geo_id: u64, i: u32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_index(entity, i)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_vertex_count(geo_id: u64) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_vertex_count(entity)).unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_index_count(geo_id: u64) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_index_count(entity)).unwrap_or(0) +} + +/// # Safety +/// - `out` must be valid for writes of `out_len` elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_get_positions( + geo_id: u64, + start: u32, + end: u32, + out: *mut [f32; 3], + out_len: u32, +) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + let positions = error::check(|| geometry_get_positions(entity, start as usize, end as usize)); + match positions { + Some(p) => { + let count = p.len().min(out_len as usize); + unsafe { std::ptr::copy_nonoverlapping(p.as_ptr(), out, count) }; + count as u32 + } + None => 0, + } +} + +/// # Safety +/// - `out` must be valid for writes of `out_len` elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_get_normals( + geo_id: u64, + start: u32, + end: u32, + out: *mut [f32; 3], + out_len: u32, +) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + let normals = error::check(|| geometry_get_normals(entity, start as usize, end as usize)); + match normals { + Some(n) => { + let count = n.len().min(out_len as usize); + unsafe { std::ptr::copy_nonoverlapping(n.as_ptr(), out, count) }; + count as u32 + } + None => 0, + } +} + +/// # Safety +/// - `out` must be valid for writes of `out_len` elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_get_colors( + geo_id: u64, + start: u32, + end: u32, + out: *mut [f32; 4], + out_len: u32, +) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + let colors = error::check(|| geometry_get_colors(entity, start as usize, end as usize)); + match colors { + Some(c) => { + let count = c.len().min(out_len as usize); + unsafe { std::ptr::copy_nonoverlapping(c.as_ptr(), out, count) }; + count as u32 + } + None => 0, + } +} + +/// # Safety +/// - `out` must be valid for writes of `out_len` elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_get_uvs( + geo_id: u64, + start: u32, + end: u32, + out: *mut [f32; 2], + out_len: u32, +) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + let uvs = error::check(|| geometry_get_uvs(entity, start as usize, end as usize)); + match uvs { + Some(u) => { + let count = u.len().min(out_len as usize); + unsafe { std::ptr::copy_nonoverlapping(u.as_ptr(), out, count) }; + count as u32 + } + None => 0, + } +} + +/// # Safety +/// - `out` must be valid for writes of `out_len` elements. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_geometry_get_indices( + geo_id: u64, + start: u32, + end: u32, + out: *mut u32, + out_len: u32, +) -> u32 { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + let indices = error::check(|| geometry_get_indices(entity, start as usize, end as usize)); + match indices { + Some(i) => { + let count = i.len().min(out_len as usize); + unsafe { std::ptr::copy_nonoverlapping(i.as_ptr(), out, count) }; + count as u32 + } + None => 0, + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_set_vertex(geo_id: u64, index: u32, x: f32, y: f32, z: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_set_vertex(entity, index, Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_set_normal( + geo_id: u64, + index: u32, + nx: f32, + ny: f32, + nz: f32, +) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_set_normal(entity, index, Vec3::new(nx, ny, nz))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_set_color( + geo_id: u64, + index: u32, + r: f32, + g: f32, + b: f32, + a: f32, +) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_set_color(entity, index, Vec4::new(r, g, b, a))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_set_uv(geo_id: u64, index: u32, u: f32, v: f32) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_set_uv(entity, index, Vec2::new(u, v))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_destroy(geo_id: u64) { + error::clear_error(); + let entity = Entity::from_bits(geo_id); + error::check(|| geometry_destroy(entity)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_model(graphics_id: u64, geo_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + let geo_entity = Entity::from_bits(geo_id); + error::check(|| graphics_record_command(graphics_entity, DrawCommand::Geometry(geo_entity))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_box(width: f32, height: f32, depth: f32) -> u64 { + error::clear_error(); + error::check(|| geometry_box(width, height, depth)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> u64 { + error::clear_error(); + error::check(|| geometry_sphere(radius, sectors, stacks)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_create_directional( + graphics_id: u64, + color: Color, + illuminance: f32, +) -> u64 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + light_create_directional(graphics_entity, color.resolve(&mode), illuminance) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_create_point( + graphics_id: u64, + color: Color, + intensity: f32, + range: f32, + radius: f32, +) -> u64 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + light_create_point( + graphics_entity, + color.resolve(&mode), + intensity, + range, + radius, + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_create_spot( + graphics_id: u64, + color: Color, + intensity: f32, + range: f32, + radius: f32, + inner_angle: f32, + outer_angle: f32, +) -> u64 { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + let mode = graphics_get_color_mode(graphics_entity)?; + light_create_spot( + graphics_entity, + color.resolve(&mode), + intensity, + range, + radius, + inner_angle, + outer_angle, + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_create_pbr() -> u64 { + error::clear_error(); + error::check(material_create_pbr) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_create_custom(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| material_create_custom(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Set a float field on a material. +/// +/// # Safety +/// - `name` is a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_material_set_float( + mat_id: u64, + name: *const std::ffi::c_char, + value: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + material_set( + Entity::from_bits(mat_id), + name, + shader_value::ShaderValue::Float(value), + ) + }); +} + +/// Set a float4 field on a material. +/// +/// # Safety +/// - `name` is a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_material_set_float4( + mat_id: u64, + name: *const std::ffi::c_char, + r: f32, + g: f32, + b: f32, + a: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + material_set( + Entity::from_bits(mat_id), + name, + shader_value::ShaderValue::Float4([r, g, b, a]), + ) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_destroy(mat_id: u64) { + error::clear_error(); + error::check(|| material_destroy(Entity::from_bits(mat_id))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material(window_id: u64, mat_id: u64) { + error::clear_error(); + let window_entity = Entity::from_bits(window_id); + let mat_entity = Entity::from_bits(mat_id); + error::check(|| graphics_record_command(window_entity, DrawCommand::Material(mat_entity))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_alpha_mode(mat_id: u64, mode: u8, cutoff: f32) { + error::clear_error(); + error::check(|| material_set_alpha_mode(Entity::from_bits(mat_id), mode, cutoff)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_double_sided(mat_id: u64, value: bool) { + error::clear_error(); + error::check(|| material_set_double_sided(Entity::from_bits(mat_id), value)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_unlit(mat_id: u64, value: bool) { + error::clear_error(); + error::check(|| material_set_unlit(Entity::from_bits(mat_id), value)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_depth_write(mat_id: u64, value: bool) { + error::clear_error(); + error::check(|| material_set_depth_write(Entity::from_bits(mat_id), value)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_custom_blend_mode( + mat_id: u64, + color_src: u8, + color_dst: u8, + color_op: u8, + alpha_src: u8, + alpha_dst: u8, + alpha_op: u8, +) { + error::clear_error(); + error::check(|| { + let blend_state = custom_blend_state( + color_src, color_dst, color_op, alpha_src, alpha_dst, alpha_op, + )?; + material_set_custom_blend(Entity::from_bits(mat_id), blend_state) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_apply_filter(graphics_id: u64, filter_id: u64) { + error::clear_error(); + error::check(|| { + graphics_apply_filter(Entity::from_bits(graphics_id), Entity::from_bits(filter_id)) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_create(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| filter_create(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_destroy(filter_id: u64) { + error::clear_error(); + error::check(|| filter_destroy(Entity::from_bits(filter_id))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_set_passes(filter_id: u64, passes: u32) { + error::clear_error(); + error::check(|| filter_set_passes(Entity::from_bits(filter_id), passes)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_blur() -> u64 { + error::clear_error(); + error::check(|| filter_blur()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_invert() -> u64 { + error::clear_error(); + error::check(|| filter_invert()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_gray() -> u64 { + error::clear_error(); + error::check(|| filter_gray()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_threshold() -> u64 { + error::clear_error(); + error::check(|| filter_threshold()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_posterize() -> u64 { + error::clear_error(); + error::check(|| filter_posterize()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_opaque() -> u64 { + error::clear_error(); + error::check(|| filter_opaque()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_erode() -> u64 { + error::clear_error(); + error::check(|| filter_erode()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_dilate() -> u64 { + error::clear_error(); + error::check(|| filter_dilate()) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Create a shader from WGSL source. +/// +/// # Safety +/// - `source` is a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_create(source: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let source = unsafe { cstr_to_str(source) }?; + shader_create(source) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `path` is a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_load(path: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let path = unsafe { cstr_to_str(path) }?; + shader_load(path) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_shader_destroy(shader_id: u64) { + error::clear_error(); + error::check(|| shader_destroy(Entity::from_bits(shader_id))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_create(size: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_create(size)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `data` is valid for `len` byte reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_create_with_data(data: *const u8, len: u64) -> u64 { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_create_with_data(bytes)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `data` is valid for `len` byte reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_write(buf_id: u64, data: *const u8, len: u64) { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_write(Entity::from_bits(buf_id), bytes)); +} + +/// Returns the byte length of a buffer, or 0 if not found (error is set). +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_size(buf_id: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_size(Entity::from_bits(buf_id))).unwrap_or(0) +} + +/// Read buffer contents into `out`. Returns the buffer's byte length; +/// `out` is only written when it fits in `out_len`. Pass `out_len == 0` for a +/// size query. +/// +/// # Safety +/// - `out` is valid for `out_len` byte writes (may be null when `out_len == 0`). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_read(buf_id: u64, out: *mut u8, out_len: u64) -> u64 { + error::clear_error(); + let Some(data) = error::check(|| buffer_read(Entity::from_bits(buf_id))) else { + return 0; + }; + let needed = data.len() as u64; + if needed <= out_len { + unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), out, data.len()) }; + } + needed +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_destroy(buf_id: u64) { + error::clear_error(); + error::check(|| buffer_destroy(Entity::from_bits(buf_id))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_create(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| compute_create(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_float( + entity: u64, + name: *const std::ffi::c_char, + value: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Float(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_int( + entity: u64, + name: *const std::ffi::c_char, + value: i32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Int(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_uint( + entity: u64, + name: *const std::ffi::c_char, + value: u32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::UInt(value)) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec2( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Float2([x, y])) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec3( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Float3([x, y, z]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_vec4( + entity: u64, + name: *const std::ffi::c_char, + x: f32, + y: f32, + z: f32, + w: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Float4([x, y, z, w]), + ) + }); +} + +/// # Safety +/// - `name` must be non-null. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_set_ivec2( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, +) { error::clear_error(); - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_layout_add_position(entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::Int2([x, y])) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_add_normal(layout_id: u64) { +pub unsafe extern "C" fn processing_shader_set_ivec3( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, + z: i32, +) { error::clear_error(); - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_layout_add_normal(entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Int3([x, y, z]), + ) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_add_color(layout_id: u64) { +pub unsafe extern "C" fn processing_shader_set_ivec4( + entity: u64, + name: *const std::ffi::c_char, + x: i32, + y: i32, + z: i32, + w: i32, +) { error::clear_error(); - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_layout_add_color(entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Int4([x, y, z, w]), + ) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_add_uv(layout_id: u64) { +pub unsafe extern "C" fn processing_shader_set_uvec2( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, +) { error::clear_error(); - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_layout_add_uv(entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set(Entity::from_bits(entity), name, ShaderValue::UInt2([x, y])) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_add_attribute(layout_id: u64, attr_id: u64) { +pub unsafe extern "C" fn processing_shader_set_uvec3( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, + z: u32, +) { error::clear_error(); - let layout_entity = Entity::from_bits(layout_id); - let attr_entity = Entity::from_bits(attr_id); - error::check(|| geometry_layout_add_attribute(layout_entity, attr_entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::UInt3([x, y, z]), + ) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_layout_destroy(layout_id: u64) { +pub unsafe extern "C" fn processing_shader_set_uvec4( + entity: u64, + name: *const std::ffi::c_char, + x: u32, + y: u32, + z: u32, + w: u32, +) { error::clear_error(); - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_layout_destroy(entity)); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::UInt4([x, y, z, w]), + ) + }); } +/// # Safety +/// - `name` must be non-null. +/// - `value` must point to at least 16 f32 elements (column-major). #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_create_with_layout(layout_id: u64, topology: u8) -> u64 { +pub unsafe extern "C" fn processing_shader_set_mat4( + entity: u64, + name: *const std::ffi::c_char, + value: *const f32, +) { error::clear_error(); - let Some(topo) = geometry::Topology::from_u8(topology) else { - error::set_error("Invalid topology"); - return 0; - }; - let entity = Entity::from_bits(layout_id); - error::check(|| geometry_create_with_layout(entity, topo)) - .map(|e| e.to_bits()) - .unwrap_or(0) + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + // SAFETY: caller guarantees 16 valid f32 elements + let m: [f32; 16] = unsafe { std::slice::from_raw_parts(value, 16) } + .try_into() + .unwrap(); + shader_set(Entity::from_bits(entity), name, ShaderValue::Mat4(m)) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_create(topology: u8) -> u64 { +pub unsafe extern "C" fn processing_shader_set_texture( + entity: u64, + name: *const std::ffi::c_char, + image_id: u64, +) { error::clear_error(); - let Some(topo) = geometry::Topology::from_u8(topology) else { - error::set_error("Invalid topology"); - return 0; - }; - error::check(|| geometry_create(topo)) - .map(|e| e.to_bits()) - .unwrap_or(0) + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Texture(Entity::from_bits(image_id)), + ) + }); } +/// # Safety +/// - `name` must be non-null. #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_normal(geo_id: u64, nx: f32, ny: f32, nz: f32) { +pub unsafe extern "C" fn processing_shader_set_buffer( + entity: u64, + name: *const std::ffi::c_char, + buf_id: u64, +) { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_normal(entity, Vec3::new(nx, ny, nz))); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + shader_set( + Entity::from_bits(entity), + name, + ShaderValue::Buffer(Entity::from_bits(buf_id)), + ) + }); } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_color(geo_id: u64, r: f32, g: f32, b: f32, a: f32) { +pub extern "C" fn processing_compute_dispatch(compute_id: u64, x: u32, y: u32, z: u32) { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_color(entity, Vec4::new(r, g, b, a))); + error::check(|| compute_dispatch(Entity::from_bits(compute_id), x, y, z)); } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_uv(geo_id: u64, u: f32, v: f32) { +pub extern "C" fn processing_compute_destroy(compute_id: u64) { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_uv(entity, u, v)); + error::check(|| compute_destroy(Entity::from_bits(compute_id))); } /// # Safety -/// - `name` must be a valid null-terminated C string. +/// - `attr_ids` is valid for `attr_count` u64 reads. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_attribute_create( - name: *const std::ffi::c_char, - format: u8, +pub unsafe extern "C" fn processing_particles_create( + capacity: u32, + attr_ids: *const u64, + attr_count: u32, ) -> u64 { error::clear_error(); - - let c_str = unsafe { std::ffi::CStr::from_ptr(name) }; - let name_str = match c_str.to_str() { - Ok(s) => s, - Err(_) => { - error::set_error("Invalid UTF-8 in attribute name"); - return 0; - } + let attrs = if attr_count > 0 && !attr_ids.is_null() { + unsafe { std::slice::from_raw_parts(attr_ids, attr_count as usize) } + .iter() + .map(|&id| Entity::from_bits(id)) + .collect() + } else { + vec![] }; + error::check(|| particles_create(capacity, attrs)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} - let attr_format = match geometry::AttributeFormat::from_u8(format) { - Some(f) => f, - None => { - error::set_error("Invalid attribute format"); - return 0; - } +/// # Safety +/// - `attr_ids` is valid for `attr_count` u64 reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_particles_create_from_geometry( + geo_id: u64, + attr_ids: *const u64, + attr_count: u32, +) -> u64 { + error::clear_error(); + let attrs = if attr_count > 0 && !attr_ids.is_null() { + unsafe { std::slice::from_raw_parts(attr_ids, attr_count as usize) } + .iter() + .map(|&id| Entity::from_bits(id)) + .collect() + } else { + vec![] }; - - error::check(|| geometry_attribute_create(name_str, attr_format)) + error::check(|| particles_create_from_geometry(Entity::from_bits(geo_id), attrs)) .map(|e| e.to_bits()) .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_destroy(attr_id: u64) { +pub extern "C" fn processing_particles_destroy(particles_id: u64) { error::clear_error(); - let entity = Entity::from_bits(attr_id); - error::check(|| geometry_attribute_destroy(entity)); + error::check(|| particles_destroy(Entity::from_bits(particles_id))); } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_position() -> u64 { - geometry_attribute_position().to_bits() +pub extern "C" fn processing_particles_capacity(particles_id: u64) -> u32 { + error::clear_error(); + error::check(|| particles_capacity(Entity::from_bits(particles_id))).unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_normal() -> u64 { - geometry_attribute_normal().to_bits() +pub extern "C" fn processing_particles_buffer(particles_id: u64, attr_id: u64) -> u64 { + error::clear_error(); + error::check(|| particles_buffer(Entity::from_bits(particles_id), Entity::from_bits(attr_id))) + .flatten() + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// # Safety +/// - `attr_ids` is valid for `attr_count` u64 reads. +/// - `attr_byte_lengths` is valid for `attr_count` u64 reads. +/// - `data` is valid for `sum(attr_byte_lengths)` byte reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_particles_emit( + particles_id: u64, + n: u32, + attr_ids: *const u64, + data: *const u8, + attr_byte_lengths: *const u64, + attr_count: u32, +) { + error::clear_error(); + error::check(|| { + if attr_count == 0 { + return particles_emit(Entity::from_bits(particles_id), n, vec![]); + } + let ids = unsafe { std::slice::from_raw_parts(attr_ids, attr_count as usize) }; + let lens = unsafe { std::slice::from_raw_parts(attr_byte_lengths, attr_count as usize) }; + let mut offset: usize = 0; + let mut attribute_data = Vec::with_capacity(attr_count as usize); + for i in 0..attr_count as usize { + let len = lens[i] as usize; + let bytes = unsafe { std::slice::from_raw_parts(data.add(offset), len) }.to_vec(); + attribute_data.push((Entity::from_bits(ids[i]), bytes)); + offset += len; + } + particles_emit(Entity::from_bits(particles_id), n, attribute_data) + }); } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_color() -> u64 { - geometry_attribute_color().to_bits() +pub extern "C" fn processing_particles_emit_gpu(particles_id: u64, n: u32, compute_id: u64) { + error::clear_error(); + error::check(|| { + particles_emit_gpu( + Entity::from_bits(particles_id), + n, + Entity::from_bits(compute_id), + ) + }); } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_uv() -> u64 { - geometry_attribute_uv().to_bits() +pub extern "C" fn processing_particles_kernel_noise() -> u64 { + error::clear_error(); + error::check(particles_kernel_noise) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_float(geo_id: u64, attr_id: u64, v: f32) { +pub extern "C" fn processing_particles_kernel_transform() -> u64 { error::clear_error(); - let geo_entity = Entity::from_bits(geo_id); - let attr_entity = Entity::from_bits(attr_id); - error::check(|| geometry_attribute_float(geo_entity, attr_entity, v)); + error::check(particles_kernel_transform) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_float2(geo_id: u64, attr_id: u64, x: f32, y: f32) { +pub extern "C" fn processing_particles_kernel_attract() -> u64 { error::clear_error(); - let geo_entity = Entity::from_bits(geo_id); - let attr_entity = Entity::from_bits(attr_id); - error::check(|| geometry_attribute_float2(geo_entity, attr_entity, x, y)); + error::check(particles_kernel_attract) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_float3( - geo_id: u64, - attr_id: u64, - x: f32, - y: f32, - z: f32, -) { +pub extern "C" fn processing_particles_kernel_drag() -> u64 { error::clear_error(); - let geo_entity = Entity::from_bits(geo_id); - let attr_entity = Entity::from_bits(attr_id); - error::check(|| geometry_attribute_float3(geo_entity, attr_entity, x, y, z)); + error::check(particles_kernel_drag) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_attribute_float4( - geo_id: u64, - attr_id: u64, - x: f32, - y: f32, - z: f32, - w: f32, -) { +pub extern "C" fn processing_particles_kernel_vortex() -> u64 { error::clear_error(); - let geo_entity = Entity::from_bits(geo_id); - let attr_entity = Entity::from_bits(attr_id); - error::check(|| geometry_attribute_float4(geo_entity, attr_entity, x, y, z, w)); + error::check(particles_kernel_vortex) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_vertex(geo_id: u64, x: f32, y: f32, z: f32) { +pub extern "C" fn processing_particles_attribute_add(particles_id: u64, attribute_id: u64) -> i32 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_vertex(entity, Vec3::new(x, y, z))); + error::check(|| { + particles_attribute_add( + Entity::from_bits(particles_id), + Entity::from_bits(attribute_id), + None, + ) + }) + .map(|_| 0) + .unwrap_or(-1) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_index(geo_id: u64, i: u32) { +pub extern "C" fn processing_particles_kernel_force() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_index(entity, i)); + error::check(particles_kernel_force) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_vertex_count(geo_id: u64) -> u32 { +pub extern "C" fn processing_particles_kernel_integrate() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_vertex_count(entity)).unwrap_or(0) + error::check(particles_kernel_integrate) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_index_count(geo_id: u64) -> u32 { +pub extern "C" fn processing_particles_kernel_age() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_index_count(entity)).unwrap_or(0) + error::check(particles_kernel_age) + .map(|e| e.to_bits()) + .unwrap_or(0) } -/// # Safety -/// - `out` must be valid for writes of `out_len` elements. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_get_positions( - geo_id: u64, - start: u32, - end: u32, - out: *mut [f32; 3], - out_len: u32, -) -> u32 { +pub extern "C" fn processing_particles_kernel_bounds_sphere() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - let positions = error::check(|| geometry_get_positions(entity, start as usize, end as usize)); - match positions { - Some(p) => { - let count = p.len().min(out_len as usize); - unsafe { std::ptr::copy_nonoverlapping(p.as_ptr(), out, count) }; - count as u32 - } - None => 0, - } + error::check(particles_kernel_bounds_sphere) + .map(|e| e.to_bits()) + .unwrap_or(0) } -/// # Safety -/// - `out` must be valid for writes of `out_len` elements. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_get_normals( - geo_id: u64, - start: u32, - end: u32, - out: *mut [f32; 3], - out_len: u32, -) -> u32 { +pub extern "C" fn processing_particles_kernel_bounds_box() -> u64 { + error::clear_error(); + error::check(particles_kernel_bounds_box) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_kernel_bounds_geometry(geometry_entity: u64) -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - let normals = error::check(|| geometry_get_normals(entity, start as usize, end as usize)); - match normals { - Some(n) => { - let count = n.len().min(out_len as usize); - unsafe { std::ptr::copy_nonoverlapping(n.as_ptr(), out, count) }; - count as u32 - } - None => 0, - } + error::check(|| particles_kernel_bounds_geometry(Entity::from_bits(geometry_entity))) + .map(|e| e.to_bits()) + .unwrap_or(0) } -/// # Safety -/// - `out` must be valid for writes of `out_len` elements. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_get_colors( - geo_id: u64, - start: u32, - end: u32, - out: *mut [f32; 4], - out_len: u32, -) -> u32 { +pub extern "C" fn processing_particles_kernel_impulse() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - let colors = error::check(|| geometry_get_colors(entity, start as usize, end as usize)); - match colors { - Some(c) => { - let count = c.len().min(out_len as usize); - unsafe { std::ptr::copy_nonoverlapping(c.as_ptr(), out, count) }; - count as u32 - } - None => 0, - } + error::check(particles_kernel_impulse) + .map(|e| e.to_bits()) + .unwrap_or(0) } -/// # Safety -/// - `out` must be valid for writes of `out_len` elements. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_get_uvs( - geo_id: u64, - start: u32, - end: u32, - out: *mut [f32; 2], - out_len: u32, -) -> u32 { +pub extern "C" fn processing_particles_kernel_flock() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - let uvs = error::check(|| geometry_get_uvs(entity, start as usize, end as usize)); - match uvs { - Some(u) => { - let count = u.len().min(out_len as usize); - unsafe { std::ptr::copy_nonoverlapping(u.as_ptr(), out, count) }; - count as u32 - } - None => 0, - } + error::check(particles_kernel_flock) + .map(|e| e.to_bits()) + .unwrap_or(0) } -/// # Safety -/// - `out` must be valid for writes of `out_len` elements. #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_geometry_get_indices( - geo_id: u64, - start: u32, - end: u32, - out: *mut u32, - out_len: u32, -) -> u32 { +pub extern "C" fn processing_particles_kernel_orient() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - let indices = error::check(|| geometry_get_indices(entity, start as usize, end as usize)); - match indices { - Some(i) => { - let count = i.len().min(out_len as usize); - unsafe { std::ptr::copy_nonoverlapping(i.as_ptr(), out, count) }; - count as u32 - } - None => 0, - } + error::check(particles_kernel_orient) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_set_vertex(geo_id: u64, index: u32, x: f32, y: f32, z: f32) { +pub extern "C" fn processing_particles_kernel_field() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_set_vertex(entity, index, Vec3::new(x, y, z))); + error::check(particles_kernel_field) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_set_normal( - geo_id: u64, - index: u32, - nx: f32, - ny: f32, - nz: f32, -) { +pub extern "C" fn processing_particles_kernel_attr_linear() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_set_normal(entity, index, Vec3::new(nx, ny, nz))); + error::check(particles_kernel_attr_linear) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_set_color( - geo_id: u64, - index: u32, - r: f32, - g: f32, - b: f32, - a: f32, -) { +pub extern "C" fn processing_particles_kernel_attr_combine() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_set_color(entity, index, Vec4::new(r, g, b, a))); + error::check(particles_kernel_attr_combine) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_set_uv(geo_id: u64, index: u32, u: f32, v: f32) { +pub extern "C" fn processing_particles_kernel_attr_mix() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_set_uv(entity, index, Vec2::new(u, v))); + error::check(particles_kernel_attr_mix) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_destroy(geo_id: u64) { +pub extern "C" fn processing_particles_kernel_attr_lookup1d() -> u64 { error::clear_error(); - let entity = Entity::from_bits(geo_id); - error::check(|| geometry_destroy(entity)); + error::check(particles_kernel_attr_lookup1d) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_model(graphics_id: u64, geo_id: u64) { +pub extern "C" fn processing_particles_kernel_attr_lookup2d() -> u64 { error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); - let geo_entity = Entity::from_bits(geo_id); - error::check(|| graphics_record_command(graphics_entity, DrawCommand::Geometry(geo_entity))); + error::check(particles_kernel_attr_lookup2d) + .map(|e| e.to_bits()) + .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_box(width: f32, height: f32, depth: f32) -> u64 { +pub extern "C" fn processing_particles_scatter_create(geometry_id: u64) -> u64 { error::clear_error(); - error::check(|| geometry_box(width, height, depth)) + error::check(|| particles_scatter_create(Entity::from_bits(geometry_id))) .map(|e| e.to_bits()) .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> u64 { +pub extern "C" fn processing_particles_scatter_volume_create(geometry_id: u64) -> u64 { error::clear_error(); - error::check(|| geometry_sphere(radius, sectors, stacks)) + error::check(|| particles_scatter_volume_create(Entity::from_bits(geometry_id))) .map(|e| e.to_bits()) .unwrap_or(0) } +/// # Safety +/// - `path` is a valid null-terminated C string. #[unsafe(no_mangle)] -pub extern "C" fn processing_light_create_directional( +pub unsafe extern "C" fn processing_gltf_load( graphics_id: u64, - color: Color, - illuminance: f32, + path: *const std::ffi::c_char, ) -> u64 { error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - let mode = graphics_get_color_mode(graphics_entity)?; - light_create_directional(graphics_entity, color.resolve(&mode), illuminance) + let path = unsafe { cstr_to_str(path) }?; + gltf_load(Entity::from_bits(graphics_id), path) }) .map(|e| e.to_bits()) .unwrap_or(0) } +/// # Safety +/// - `name` is a valid null-terminated C string. #[unsafe(no_mangle)] -pub extern "C" fn processing_light_create_point( - graphics_id: u64, - color: Color, - intensity: f32, - range: f32, - radius: f32, +pub unsafe extern "C" fn processing_gltf_geometry( + gltf_id: u64, + name: *const std::ffi::c_char, ) -> u64 { error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - let mode = graphics_get_color_mode(graphics_entity)?; - light_create_point( - graphics_entity, - color.resolve(&mode), - intensity, - range, - radius, - ) + let name = unsafe { cstr_to_str(name) }?; + gltf_geometry(Entity::from_bits(gltf_id), name) }) .map(|e| e.to_bits()) .unwrap_or(0) } +/// # Safety +/// - `name` is a valid null-terminated C string. #[unsafe(no_mangle)] -pub extern "C" fn processing_light_create_spot( - graphics_id: u64, - color: Color, - intensity: f32, - range: f32, - radius: f32, - inner_angle: f32, - outer_angle: f32, +pub unsafe extern "C" fn processing_gltf_material( + gltf_id: u64, + name: *const std::ffi::c_char, ) -> u64 { error::clear_error(); - let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - let mode = graphics_get_color_mode(graphics_entity)?; - light_create_spot( - graphics_entity, - color.resolve(&mode), - intensity, - range, - radius, - inner_angle, - outer_angle, - ) + let name = unsafe { cstr_to_str(name) }?; + gltf_material(Entity::from_bits(gltf_id), name) }) .map(|e| e.to_bits()) .unwrap_or(0) } #[unsafe(no_mangle)] -pub extern "C" fn processing_material_create_pbr() -> u64 { +pub extern "C" fn processing_gltf_camera(gltf_id: u64, index: u32) { error::clear_error(); - error::check(material_create_pbr) + error::check(|| gltf_camera(Entity::from_bits(gltf_id), index as usize)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_gltf_light(gltf_id: u64, index: u32) -> u64 { + error::clear_error(); + error::check(|| gltf_light(Entity::from_bits(gltf_id), index as usize)) .map(|e| e.to_bits()) .unwrap_or(0) } -/// Set float value for `name` field on Material. -/// -/// # Safety -/// - `name` must be non-null #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_material_set_float( - mat_id: u64, - name: *const std::ffi::c_char, - value: f32, -) { +pub extern "C" fn processing_particles_apply(particles_id: u64, compute_id: u64) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); error::check(|| { - material_set( - Entity::from_bits(mat_id), - name, - material::MaterialValue::Float(value), + particles_apply( + Entity::from_bits(particles_id), + Entity::from_bits(compute_id), ) }); } -/// Set float4 value for `name` field on Material. -/// -/// # Safety -/// - `name` must be non-null #[unsafe(no_mangle)] -pub unsafe extern "C" fn processing_material_set_float4( - mat_id: u64, - name: *const std::ffi::c_char, - r: f32, - g: f32, - b: f32, - a: f32, -) { +pub extern "C" fn processing_particles_draw(graphics_id: u64, particles_id: u64, geometry_id: u64) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); + let graphics_entity = Entity::from_bits(graphics_id); error::check(|| { - material_set( - Entity::from_bits(mat_id), - name, - material::MaterialValue::Float4([r, g, b, a]), + graphics_record_command( + graphics_entity, + DrawCommand::Particles { + particles: Entity::from_bits(particles_id), + geometry: Some(Entity::from_bits(geometry_id)), + topology: geometry::Topology::PointList, + }, ) }); } #[unsafe(no_mangle)] -pub extern "C" fn processing_material_destroy(mat_id: u64) { +pub extern "C" fn processing_fill_buffer(graphics_id: u64, buffer_id: u64) { error::clear_error(); - error::check(|| material_destroy(Entity::from_bits(mat_id))); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::FillBuffer(Entity::from_bits(buffer_id)), + ) + }); } #[unsafe(no_mangle)] -pub extern "C" fn processing_material(window_id: u64, mat_id: u64) { +pub extern "C" fn processing_material_set_albedo_buffer(mat_id: u64, buffer_id: u64) { error::clear_error(); - let window_entity = Entity::from_bits(window_id); - let mat_entity = Entity::from_bits(mat_id); - error::check(|| graphics_record_command(window_entity, DrawCommand::Material(mat_entity))); + error::check(|| { + material_set_albedo_buffer(Entity::from_bits(mat_id), Entity::from_bits(buffer_id)) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_emissive_buffer(mat_id: u64, buffer_id: u64) { + error::clear_error(); + error::check(|| { + material_set_emissive_buffer(Entity::from_bits(mat_id), Entity::from_bits(buffer_id)) + }); +} + +/// # Safety +/// - `out_x`, `out_y`, `out_z` are each valid for one f32 write. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_world_from_screen( + graphics_id: u64, + sx: f32, + sy: f32, + depth: f32, + out_x: *mut f32, + out_y: *mut f32, + out_z: *mut f32, +) { + error::clear_error(); + if let Some(world) = + error::check(|| graphics_world_from_screen(Entity::from_bits(graphics_id), sx, sy, depth)) + { + unsafe { + *out_x = world.x; + *out_y = world.y; + *out_z = world.z; + } + } +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_set_bloom(graphics_id: u64, intensity: f32, threshold: f32) { + error::clear_error(); + error::check(|| graphics_set_bloom(Entity::from_bits(graphics_id), intensity, threshold)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_remove_bloom(graphics_id: u64) { + error::clear_error(); + error::check(|| graphics_remove_bloom(Entity::from_bits(graphics_id))); } // Mouse buttons @@ -2005,6 +4070,43 @@ pub extern "C" fn processing_input_focus(surface_id: u64, focused: bool) { error::check(|| input_set_focus(Entity::from_bits(surface_id), focused)); } +pub const PROCESSING_CURSOR_ARROW: u8 = 0; +pub const PROCESSING_CURSOR_CROSS: u8 = 1; +pub const PROCESSING_CURSOR_HAND: u8 = 2; +pub const PROCESSING_CURSOR_MOVE: u8 = 3; +pub const PROCESSING_CURSOR_TEXT: u8 = 4; +pub const PROCESSING_CURSOR_WAIT: u8 = 5; + +fn cursor_icon(kind: u8) -> bevy::window::SystemCursorIcon { + use bevy::window::SystemCursorIcon; + match kind { + PROCESSING_CURSOR_CROSS => SystemCursorIcon::Crosshair, + PROCESSING_CURSOR_HAND => SystemCursorIcon::Pointer, + PROCESSING_CURSOR_MOVE => SystemCursorIcon::Move, + PROCESSING_CURSOR_TEXT => SystemCursorIcon::Text, + PROCESSING_CURSOR_WAIT => SystemCursorIcon::Wait, + _ => SystemCursorIcon::Default, + } +} + +/// Show the mouse cursor with the given system type (PROCESSING_CURSOR_*). +#[unsafe(no_mangle)] +pub extern "C" fn processing_cursor(surface_id: u64, kind: u8) { + error::clear_error(); + let surface = Entity::from_bits(surface_id); + error::check(|| { + input_set_cursor_visible(surface, true)?; + input_set_cursor_icon(surface, cursor_icon(kind)) + }); +} + +/// Hide the mouse cursor. +#[unsafe(no_mangle)] +pub extern "C" fn processing_no_cursor(surface_id: u64) { + error::clear_error(); + error::check(|| input_set_cursor_visible(Entity::from_bits(surface_id), false)); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_input_flush() { error::clear_error(); diff --git a/crates/processing_glfw/Cargo.toml b/crates/processing_glfw/Cargo.toml index 2730ba0a..01ebacb6 100644 --- a/crates/processing_glfw/Cargo.toml +++ b/crates/processing_glfw/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_glfw" -version = "0.0.1" +version = "0.0.5" edition = "2024" [lints] @@ -8,10 +8,11 @@ workspace = true [features] wayland = ["glfw/wayland"] -x11 = [] +x11 = ["dep:x11"] static-link = ["glfw/static-link"] [dependencies] +x11 = { version = "2", features = ["xlib"], optional = true } bevy = { workspace = true } glfw = "0.60.0" processing_core = { workspace = true } diff --git a/crates/processing_glfw/src/lib.rs b/crates/processing_glfw/src/lib.rs index b8d844e5..9bf6a2f2 100644 --- a/crates/processing_glfw/src/lib.rs +++ b/crates/processing_glfw/src/lib.rs @@ -13,12 +13,19 @@ use processing_core::error::Result; use processing_input::{ input_cursor_grab_mode, input_cursor_visible, input_flush, input_set_char, input_set_cursor_enter, input_set_cursor_leave, input_set_focus, input_set_key, - input_set_mouse_button, input_set_mouse_move, input_set_scroll, + input_set_mouse_button, input_set_mouse_move, input_set_scroll, input_window_resize, }; use processing_render::surface::{MonitorWorkarea, WindowControls}; +/// A single GLFW instance drives every window (GLFW's event pump is global). The +/// main window is `windows[0]`; `create_window` appends more. pub struct GlfwContext { glfw: Glfw, + windows: Vec, +} + +/// Per-window state. One `GlfwContext` owns many of these on the shared instance. +struct ManagedWindow { window: PWindow, events: GlfwReceiver<(f64, WindowEvent)>, surface: Option, @@ -58,28 +65,72 @@ impl Default for AppliedWindow { } impl GlfwContext { - pub fn new(width: u32, height: u32) -> Result { + pub fn new(width: u32, height: u32, transparent: bool) -> Result { let mut glfw = glfw::init(glfw::fail_on_errors).unwrap(); + let main = Self::spawn_window(&mut glfw, width, height, transparent, "Processing"); + Ok(Self { + glfw, + windows: vec![main], + }) + } + /// Create a GLFW window on the shared instance and return its per-window state. + fn spawn_window( + glfw: &mut Glfw, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> ManagedWindow { glfw.window_hint(glfw::WindowHint::ClientApi(glfw::ClientApiHint::NoApi)); glfw.window_hint(glfw::WindowHint::Visible(false)); - glfw.window_hint(glfw::WindowHint::TransparentFramebuffer(true)); + // Window transparency is an explicit opt-in; an opaque framebuffer is the + // default (a transparent-by-default window is surprising and platform-flaky). + glfw.window_hint(glfw::WindowHint::TransparentFramebuffer(transparent)); let (mut window, events) = glfw - .create_window(width, height, "Processing", WindowMode::Windowed) + .create_window(width, height, title, WindowMode::Windowed) .unwrap(); window.set_all_polling(true); + + // Set _NET_WM_WINDOW_TYPE_DIALOG so tiling WMs (i3, sway) float the window + #[cfg(all(target_os = "linux", feature = "x11"))] + unsafe { + use std::ffi::CString; + let display = window.glfw.get_x11_display() as *mut x11::xlib::Display; + let xwindow = window.get_x11_window() as x11::xlib::Window; + let net_wm_window_type = x11::xlib::XInternAtom( + display, + CString::new("_NET_WM_WINDOW_TYPE").unwrap().as_ptr(), + 0, + ); + let net_wm_window_type_dialog = x11::xlib::XInternAtom( + display, + CString::new("_NET_WM_WINDOW_TYPE_DIALOG").unwrap().as_ptr(), + 0, + ); + x11::xlib::XChangeProperty( + display, + xwindow, + net_wm_window_type, + x11::xlib::XA_ATOM, + 32, + x11::xlib::PropModeReplace, + &net_wm_window_type_dialog as *const _ as *const u8, + 1, + ); + } + window.show(); - Ok(Self { - glfw, + ManagedWindow { window, events, surface: None, last_applied: AppliedWindow::default(), windowed_geometry: None, - }) + } } fn sync_monitors(&mut self) { @@ -178,67 +229,112 @@ impl GlfwContext { }); } - #[cfg(target_os = "macos")] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_macos; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_macos( - self.window.get_cocoa_window() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// Create the render surface for the main window (index 0). + pub fn create_surface(&mut self, width: u32, height: u32, transparent: bool) -> Result { + self.create_surface_for(0, width, height, transparent) } - #[cfg(target_os = "windows")] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_windows; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_windows( - self.window.get_win32_window() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// Create an additional window on the shared GLFW instance plus its render + /// surface, and return the new window's surface entity. + pub fn add_window( + &mut self, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> Result { + let mw = Self::spawn_window(&mut self.glfw, width, height, transparent, title); + self.windows.push(mw); + let idx = self.windows.len() - 1; + self.create_surface_for(idx, width, height, transparent) } - #[cfg(all(target_os = "linux", feature = "wayland"))] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_wayland; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_wayland( - self.window.get_wayland_window() as u64, - self.glfw.get_wayland_display() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); - Ok(entity) + /// The surface entity of the main window, if created. + pub fn main_surface(&self) -> Option { + self.windows.first().and_then(|w| w.surface) } - #[cfg(all(target_os = "linux", feature = "x11"))] - pub fn create_surface(&mut self, width: u32, height: u32) -> Result { - use processing_render::surface_create_x11; - let (scale_factor, _) = self.window.get_content_scale(); - let entity = surface_create_x11( - self.window.get_x11_window() as u64, - self.glfw.get_x11_display() as u64, - width, - height, - scale_factor, - )?; - self.surface = Some(entity); + fn create_surface_for( + &mut self, + idx: usize, + width: u32, + height: u32, + transparent: bool, + ) -> Result { + let (scale_factor, _) = self.windows[idx].window.get_content_scale(); + + #[cfg(target_os = "macos")] + let entity = { + use processing_render::surface_create_macos; + let handle = self.windows[idx].window.get_cocoa_window() as u64; + surface_create_macos(handle, width, height, scale_factor, transparent)? + }; + #[cfg(target_os = "windows")] + let entity = { + use processing_render::surface_create_windows; + let handle = self.windows[idx].window.get_win32_window() as u64; + surface_create_windows(handle, width, height, scale_factor, transparent)? + }; + #[cfg(all(target_os = "linux", feature = "wayland"))] + let entity = { + use processing_render::surface_create_wayland; + let wh = self.windows[idx].window.get_wayland_window() as u64; + let dh = self.glfw.get_wayland_display() as u64; + surface_create_wayland(wh, dh, width, height, scale_factor, transparent)? + }; + #[cfg(all(target_os = "linux", feature = "x11", not(feature = "wayland")))] + let entity = { + use processing_render::surface_create_x11; + let wh = self.windows[idx].window.get_x11_window() as u64; + let dh = self.glfw.get_x11_display() as u64; + surface_create_x11(wh, dh, width, height, scale_factor, transparent)? + }; + + self.windows[idx].surface = Some(entity); Ok(entity) } pub fn poll_events(&mut self) -> bool { self.glfw.poll_events(); + self.sync_monitors(); + + // GLFW's pump is global; flush + sync each window on the shared instance. + // A closed secondary window is dropped; a closed main window ends the loop. + let GlfwContext { glfw, windows } = self; + let mut main_open = true; + let mut i = 0; + while i < windows.len() { + if windows[i].poll(glfw) { + i += 1; + } else if i == 0 { + main_open = false; + i += 1; + } else { + windows[i].window.hide(); + windows.remove(i); + } + } + // Input is accumulated per-window above; commit it once for the frame. + if input_flush().is_err() { + return false; + } + main_open + } + + /// Content scale (DPI) of the main window. + pub fn content_scale(&self) -> f32 { + self.windows + .first() + .map(|w| w.window.get_content_scale().0) + .unwrap_or(1.0) + } +} + +impl ManagedWindow { + /// Flush this window's events and sync its OS state; returns whether it's + /// still open. Input is committed once per frame by the caller. + fn poll(&mut self, glfw: &mut Glfw) -> bool { let surface = match self.surface { Some(s) => s, None => { @@ -256,6 +352,7 @@ impl GlfwContext { } }; + let mut pending_resize: Option<(i32, i32)> = None; for (_, event) in glfw::flush_messages(&self.events) { match event { WindowEvent::Close => { @@ -300,6 +397,10 @@ impl GlfwContext { WindowEvent::Focus(focused) => { input_set_focus(surface, focused).unwrap(); } + WindowEvent::Size(width, height) => { + // A drag delivers many Size events per poll; keep only the last and apply it once after the loop. + pending_resize = Some((width, height)); + } _ => {} } } @@ -309,22 +410,24 @@ impl GlfwContext { return false; } - let Ok(_) = input_flush() else { - return false; - }; + // Apply the coalesced resize once, the WindowResized message and Window change are processed this poll. + if let Some((width, height)) = pending_resize { + input_window_resize(surface, width as f32, height as f32).unwrap(); + processing_render::surface_resize(surface, width as u32, height as u32).unwrap(); + } + self.sync_cursor(surface); - self.sync_monitors(); - self.sync_window(surface); + self.sync_window(glfw, surface); true } - fn sync_window(&mut self, surface: Entity) { + fn sync_window(&mut self, glfw: &mut Glfw, surface: Entity) { let Some(desired) = read_desired_window(surface) else { return; }; - self.apply_window(&desired); + self.apply_window(glfw, &desired); if desired.iconify { self.window.iconify(); @@ -335,13 +438,12 @@ impl GlfwContext { if desired.maximize { self.window.maximize(); } + #[cfg(not(all(target_os = "linux", feature = "wayland")))] if desired.focus { self.window.focus(); } - let (cx, cy) = self.window.get_pos(); - let (inset_l, inset_t, _, _) = self.window.get_frame_size(); - let frame_pos = IVec2::new(cx - inset_l, cy - inset_t); + let frame_pos = self.frame_pos(); let _ = app_mut(|app| { let world = app.world_mut(); if let Some(mut window) = world.get_mut::(surface) { @@ -356,15 +458,31 @@ impl GlfwContext { Ok(()) }); self.last_applied.position = frame_pos; + + let (w, h) = self.window.get_size(); + self.last_applied.size = bevy::math::UVec2::new(w.max(0) as u32, h.max(0) as u32); } - fn apply_window(&mut self, desired: &DesiredWindow) { + #[cfg(not(feature = "wayland"))] + fn frame_pos(&self) -> IVec2 { + let (cx, cy) = self.window.get_pos(); + let (inset_l, inset_t, _, _) = self.window.get_frame_size(); + IVec2::new(cx - inset_l, cy - inset_t) + } + + #[cfg(feature = "wayland")] + fn frame_pos(&self) -> IVec2 { + self.last_applied.position + } + + fn apply_window(&mut self, glfw: &mut Glfw, desired: &DesiredWindow) { let last = &mut self.last_applied; if desired.title != last.title { self.window.set_title(&desired.title); last.title.clone_from(&desired.title); } + #[cfg(not(feature = "wayland"))] if let Some(pos) = desired.position && pos != last.position { @@ -394,6 +512,7 @@ impl GlfwContext { last.decorations = desired.decorations; } if desired.window_level != last.window_level { + #[cfg(not(all(target_os = "linux", feature = "wayland")))] self.window .set_floating(matches!(desired.window_level, BevyWindowLevel::AlwaysOnTop)); last.window_level = desired.window_level; @@ -401,25 +520,26 @@ impl GlfwContext { if let Some(opacity) = desired.opacity && (opacity - last.opacity).abs() > f32::EPSILON { + #[cfg(not(all(target_os = "linux", feature = "wayland")))] self.window.set_opacity(opacity); last.opacity = opacity; } if desired.fullscreen_on != last.fullscreen_on { - self.apply_fullscreen(desired.fullscreen_on); + self.apply_fullscreen(glfw, desired.fullscreen_on); } } - fn apply_fullscreen(&mut self, target: Option) { + fn apply_fullscreen(&mut self, glfw: &mut Glfw, target: Option) { match target { Some(monitor_entity) => { if self.last_applied.fullscreen_on.is_none() { - let (x, y) = self.window.get_pos(); let (w, h) = self.window.get_size(); - self.windowed_geometry = Some((x, y, w as u32, h as u32)); + let pos = self.frame_pos(); + self.windowed_geometry = Some((pos.x, pos.y, w as u32, h as u32)); } let target_name = monitor_name(monitor_entity); let window = &mut self.window; - let applied = self.glfw.with_connected_monitors(|_, monitors| { + let applied = glfw.with_connected_monitors(|_, monitors| { let Some(monitor) = monitors .iter() .find(|m| m.get_name() == target_name) @@ -438,9 +558,9 @@ impl GlfwContext { } None => { let (x, y, w, h) = self.windowed_geometry.take().unwrap_or_else(|| { - let (x, y) = self.window.get_pos(); let (w, h) = self.window.get_size(); - (x, y, w as u32, h as u32) + let pos = self.frame_pos(); + (pos.x, pos.y, w as u32, h as u32) }); self.window .set_monitor(WindowMode::Windowed, x, y, w, h, None); @@ -449,11 +569,6 @@ impl GlfwContext { } } - pub fn content_scale(&self) -> f32 { - let (s, _) = self.window.get_content_scale(); - s - } - fn sync_cursor(&mut self, surface: Entity) { use bevy::window::CursorGrabMode; @@ -475,6 +590,7 @@ impl GlfwContext { #[derive(Clone, Debug)] struct DesiredWindow { title: String, + #[cfg(not(feature = "wayland"))] position: Option, size: bevy::math::UVec2, visible: bool, @@ -486,6 +602,7 @@ struct DesiredWindow { iconify: bool, restore: bool, maximize: bool, + #[cfg(not(all(target_os = "linux", feature = "wayland")))] focus: bool, } @@ -507,13 +624,14 @@ fn read_desired_window(surface: Entity) -> Option { }; Ok(Some(DesiredWindow { title: window.title.clone(), + #[cfg(not(feature = "wayland"))] position: match window.position { WindowPosition::At(p) => Some(p), _ => None, }, size: bevy::math::UVec2::new( - window.resolution.physical_width(), - window.resolution.physical_height(), + window.resolution.width() as u32, + window.resolution.height() as u32, ), visible: window.visible, resizable: window.resizable, @@ -524,6 +642,7 @@ fn read_desired_window(surface: Entity) -> Option { iconify: controls.pending_iconify, restore: controls.pending_restore, maximize: controls.pending_maximize, + #[cfg(not(all(target_os = "linux", feature = "wayland")))] focus: controls.pending_focus, })) }) diff --git a/crates/processing_input/Cargo.toml b/crates/processing_input/Cargo.toml index f06e6e21..02cc7424 100644 --- a/crates/processing_input/Cargo.toml +++ b/crates/processing_input/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_input" -version = "0.0.1" +version = "0.0.5" edition = "2024" [lints] diff --git a/crates/processing_input/src/lib.rs b/crates/processing_input/src/lib.rs index 56de9575..4ee0bfd4 100644 --- a/crates/processing_input/src/lib.rs +++ b/crates/processing_input/src/lib.rs @@ -8,7 +8,7 @@ use bevy::input::mouse::{ }; use bevy::input::touch::TouchPhase; use bevy::prelude::*; -use bevy::window::CursorMoved; +use bevy::window::{CursorMoved, WindowResized}; use processing_core::app_mut; use processing_core::error; @@ -177,6 +177,30 @@ pub fn input_cursor_visible(surface: Entity) -> error::Result { }) } +pub fn input_set_cursor_visible(surface: Entity, visible: bool) -> error::Result<()> { + app_mut(|app| { + if let Some(mut cursor) = app + .world_mut() + .get_mut::(surface) + { + cursor.visible = visible; + } + Ok(()) + }) +} + +pub fn input_set_cursor_icon( + surface: Entity, + icon: bevy::window::SystemCursorIcon, +) -> error::Result<()> { + app_mut(|app| { + if let Ok(mut entity) = app.world_mut().get_entity_mut(surface) { + entity.insert(bevy::window::CursorIcon::System(icon)); + } + Ok(()) + }) +} + /// Flushes the input state by running the relevant schedules. This is required to ensure that /// Bevy's bookkeeping of input state is up to date after manually sending input events. /// It should be called after sending any input events and before querying input state @@ -342,3 +366,14 @@ pub fn input_mouse_scrolled() -> error::Result { Ok(d.x != 0.0 || d.y != 0.0) }) } + +pub fn input_window_resize(surface: Entity, width: f32, height: f32) -> error::Result<()> { + app_mut(|app| { + app.world_mut().write_message(WindowResized { + window: surface, + width, + height, + }); + Ok(()) + }) +} diff --git a/crates/processing_midi/Cargo.toml b/crates/processing_midi/Cargo.toml index 049e609a..593cc416 100644 --- a/crates/processing_midi/Cargo.toml +++ b/crates/processing_midi/Cargo.toml @@ -1,12 +1,12 @@ [package] name = "processing_midi" -version = "0.0.1" +version = "0.0.5" edition = "2024" [dependencies] bevy = { workspace = true } processing_core = { workspace = true } -bevy_midi = { git = "https://github.com/BlackPhlox/bevy_midi", branch = "latest" } +nannou_midi = { git = "https://github.com/nannou-org/nannou", branch = "bevy-refactor" } [lints] workspace = true diff --git a/crates/processing_midi/src/lib.rs b/crates/processing_midi/src/lib.rs index 8ee15d70..c8e9a6ba 100644 --- a/crates/processing_midi/src/lib.rs +++ b/crates/processing_midi/src/lib.rs @@ -1,62 +1,88 @@ use bevy::prelude::*; -use bevy_midi::prelude::*; +use nannou_midi::{MidiOutput, MidiOutputStream, MidiPort, MidiPortDirection}; use processing_core::app_mut; use processing_core::error::{self, Result}; +pub use nannou_midi::{MidiData, MidiMessage}; + pub struct MidiPlugin; pub const NOTE_ON: u8 = 0b1001_0000; pub const NOTE_OFF: u8 = 0b1000_0000; +#[derive(Resource, Default)] +struct ActiveOutput(Option); + impl Plugin for MidiPlugin { fn build(&self, app: &mut App) { - // TODO: Update `bevy_midi` to treat connections as entities - // in order to support hot-plugging - app.insert_resource(MidiOutputSettings { - port_name: "libprocessing output", - }); - - app.add_plugins(MidiOutputPlugin); + app.init_resource::(); + app.add_plugins(nannou_midi::MidiPlugin); } } -pub fn connect(In(port): In, output: Res) -> Result<()> { - match output.ports().get(port) { - Some((_, p)) => { - output.connect(p.clone()); - Ok(()) - } - None => Err(error::ProcessingError::MidiPortNotFound(port)), - } -} - -pub fn disconnect(output: Res) -> Result<()> { - output.disconnect(); - Ok(()) +fn sorted_output_ports(world: &mut World) -> Vec<(Entity, String)> { + let mut q = world.query::<(Entity, &Name, &MidiPort)>(); + let mut ports: Vec<(Entity, String)> = q + .iter(world) + .filter(|(_, _, p)| p.direction == MidiPortDirection::Output) + .map(|(e, n, _)| (e, n.as_str().to_string())) + .collect(); + ports.sort_by(|a, b| a.1.cmp(&b.1)); + ports } -pub fn refresh_ports(output: Res) -> Result<()> { - output.refresh_ports(); - Ok(()) -} - -pub fn list_ports(output: Res) -> Result> { - Ok(output - .ports() - .iter() +pub fn list_ports(world: &mut World) -> Result> { + Ok(sorted_output_ports(world) + .into_iter() .enumerate() - .map(|(i, (name, _))| format!("{}: {}", i, name)) + .map(|(i, (_, name))| format!("{i}: {name}")) .collect()) } -pub fn play_notes(In((note, duration)): In<(u8, u64)>, output: Res) -> Result<()> { - output.send([NOTE_ON, note, 127].into()); // Note on, channel 1, max velocity +pub fn connect(In(port): In, world: &mut World) -> Result<()> { + let port_entity = sorted_output_ports(world) + .get(port) + .map(|(e, _)| *e) + .ok_or(error::ProcessingError::MidiPortNotFound(port))?; + + let previous = world.resource::().0; + if let Some(e) = previous + && let Ok(entity) = world.get_entity_mut(e) + { + entity.despawn(); + } - std::thread::sleep(std::time::Duration::from_millis(duration)); + let connection = world + .spawn(( + Name::new("libprocessing output"), + MidiOutput { + port: Some(port_entity), + }, + )) + .id(); + world.resource_mut::().0 = Some(connection); + Ok(()) +} - output.send([NOTE_OFF, note, 127].into()); // Note off, channel 1, max velocity +pub fn disconnect(world: &mut World) -> Result<()> { + let entity = world.resource_mut::().0.take(); + if let Some(e) = entity + && let Ok(entity_mut) = world.get_entity_mut(e) + { + entity_mut.despawn(); + } + Ok(()) +} +pub fn send_message(In(msg): In, world: &mut World) -> Result<()> { + let entity = world + .resource::() + .0 + .ok_or(error::ProcessingError::MidiPortNotFound(usize::MAX))?; + if let Some(mut stream) = world.get_mut::(entity) { + stream.send(msg); + } Ok(()) } @@ -64,12 +90,9 @@ pub fn play_notes(In((note, duration)): In<(u8, u64)>, output: Res) pub fn midi_refresh_ports() -> error::Result<()> { app_mut(|app| { let world = app.world_mut(); - world.run_system_cached(refresh_ports).unwrap() - })?; - // run the `PreUpdate` schedule to let `bevy_midi` process it's callbacks and update the ports list - // TODO: race condition is still present here in theory - app_mut(|app| { - app.world_mut().run_schedule(PreUpdate); + world + .run_system_cached(nannou_midi::native::enumerate_midi_ports) + .unwrap(); Ok(()) }) } @@ -86,7 +109,12 @@ pub fn midi_list_ports() -> error::Result> { pub fn midi_connect(port: usize) -> error::Result<()> { app_mut(|app| { let world = app.world_mut(); - world.run_system_cached_with(connect, port).unwrap() + world.run_system_cached_with(connect, port).unwrap()?; + // Materialize the MidiOutputStream component before the caller sends. + world + .run_system_cached(nannou_midi::native::open_midi_outputs) + .unwrap(); + Ok(()) }) } @@ -98,12 +126,57 @@ pub fn midi_disconnect() -> error::Result<()> { }) } +#[cfg(not(target_arch = "wasm32"))] +pub fn midi_note_on(note: u8, velocity: u8) -> error::Result<()> { + app_mut(|app| { + let world = app.world_mut(); + world + .run_system_cached_with(send_message, MidiMessage::from([NOTE_ON, note, velocity])) + .unwrap()?; + world + .run_system_cached(nannou_midi::native::send_midi_messages) + .unwrap(); + Ok(()) + }) +} + +#[cfg(not(target_arch = "wasm32"))] +pub fn midi_note_off(note: u8) -> error::Result<()> { + app_mut(|app| { + let world = app.world_mut(); + world + .run_system_cached_with(send_message, MidiMessage::from([NOTE_OFF, note, 0])) + .unwrap()?; + world + .run_system_cached(nannou_midi::native::send_midi_messages) + .unwrap(); + Ok(()) + }) +} + #[cfg(not(target_arch = "wasm32"))] pub fn midi_play_notes(note: u8, duration: u64) -> error::Result<()> { app_mut(|app| { let world = app.world_mut(); world - .run_system_cached_with(play_notes, (note, duration)) - .unwrap() + .run_system_cached_with(send_message, MidiMessage::from([NOTE_ON, note, 127])) + .unwrap()?; + world + .run_system_cached(nannou_midi::native::send_midi_messages) + .unwrap(); + Ok(()) + })?; + + std::thread::sleep(std::time::Duration::from_millis(duration)); + + app_mut(|app| { + let world = app.world_mut(); + world + .run_system_cached_with(send_message, MidiMessage::from([NOTE_OFF, note, 127])) + .unwrap()?; + world + .run_system_cached(nannou_midi::native::send_midi_messages) + .unwrap(); + Ok(()) }) } diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 5e8bc0fe..2eb65a94 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_pyo3" -version = "0.0.3" +version = "0.0.8" edition = "2024" [lints] @@ -14,13 +14,14 @@ crate-type = ["cdylib"] default = ["wayland", "static-link"] wayland = ["processing/wayland", "processing_glfw/wayland"] static-link = ["processing_glfw/static-link"] -x11 = ["processing/x11"] +x11 = ["processing/x11", "processing_glfw/x11"] webcam = ["processing/webcam", "dep:processing_webcam"] cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] [dependencies] pyo3 = { workspace = true, features = ["experimental-inspect", "multiple-pymethods"] } processing = { workspace = true } +processing_render = { workspace = true } processing_webcam = { workspace = true, optional = true } processing_glfw = { workspace = true } bevy = { workspace = true, features = ["file_watcher"] } diff --git a/crates/processing_pyo3/examples/animated_mesh.py b/crates/processing_pyo3/examples/animated_mesh.py index 5001cb54..934fd8b7 100644 --- a/crates/processing_pyo3/examples/animated_mesh.py +++ b/crates/processing_pyo3/examples/animated_mesh.py @@ -11,7 +11,7 @@ def setup(): global geometry size(800, 600) mode_3d() - geometry = Geometry() + geometry = create_geometry() for z in range(grid_size): for x in range(grid_size): px = x * spacing - offset diff --git a/crates/processing_pyo3/examples/background_image.py b/crates/processing_pyo3/examples/background_image.py index 90302c80..4e29b081 100644 --- a/crates/processing_pyo3/examples/background_image.py +++ b/crates/processing_pyo3/examples/background_image.py @@ -3,7 +3,7 @@ def setup(): global i size(800, 600) - i = image("images/logo.png") + i = load_image("images/logo.png") def draw(): diff --git a/crates/processing_pyo3/examples/camera_controllers.py b/crates/processing_pyo3/examples/camera_controllers.py index 7feae0f5..562d5b63 100644 --- a/crates/processing_pyo3/examples/camera_controllers.py +++ b/crates/processing_pyo3/examples/camera_controllers.py @@ -8,7 +8,7 @@ def setup(): mode_3d() orbit_camera() - dir_light = create_directional_light((1.0, 0.98, 0.95), 1500.0) + dir_light = directional_light((1.0, 0.98, 0.95), 1500.0) dir_light.position(300.0, 400.0, 300.0) dir_light.look_at(0.0, 0.0, 0.0) diff --git a/crates/processing_pyo3/examples/compute.py b/crates/processing_pyo3/examples/compute.py new file mode 100644 index 00000000..59d56229 --- /dev/null +++ b/crates/processing_pyo3/examples/compute.py @@ -0,0 +1,106 @@ +import struct + +from mewnala import Graphics, Shader, Compute, Buffer + +g = Graphics.new_offscreen(1, 1, "", None) +g.begin_draw() + +shader = create_shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(1) +fn main() { + output[0] = 1u; + output[1] = 2u; + output[2] = 3u; + output[3] = 4u; +} +""") + +buf = create_buffer(size=16) +compute = create_compute(shader) +compute.set(output=buf) +compute.dispatch(1, 1, 1) + +data = buf.read() +assert isinstance(data, bytes), f"expected bytes, got {type(data)}" +assert list(struct.unpack("<4I", data)) == [1, 2, 3, 4] +print("PASS") + + +buf2 = create_buffer(data=[10.0, 20.0, 30.0, 40.0]) +assert len(buf2) == 4 +assert buf2[0] == 10.0 +assert buf2[-1] == 40.0 +assert buf2[1:3] == [20.0, 30.0] + +buf2[2] = 99.0 +assert buf2[2] == 99.0 + +buf2[0:2] = [111.0, 222.0] +assert buf2[0] == 111.0 +assert buf2[1] == 222.0 +print("PASS") + + +double_shader = create_shader(""" +@group(0) @binding(0) +var data: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + data[id.x] = data[id.x] * 2.0; +} +""") + +buf3 = create_buffer(data=[1.0, 2.0, 3.0, 4.0]) +compute3 = create_compute(double_shader) +compute3.set(data=buf3) +compute3.dispatch(1, 1, 1) +assert buf3.read() == [2.0, 4.0, 6.0, 8.0] +print("PASS") + + +compute3.dispatch(1, 1, 1) +assert buf3.read() == [4.0, 8.0, 12.0, 16.0] +print("PASS") + + +wg_shader = create_shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + output[id.x] = id.x + 1u; +} +""") + +buf5 = create_buffer(size=32) +compute5 = create_compute(wg_shader) +compute5.set(output=buf5) +compute5.dispatch(2, 1, 1) +assert list(struct.unpack("<8I", buf5.read())) == [1, 2, 3, 4, 5, 6, 7, 8] +print("PASS") + + +copy_shader = create_shader(""" +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + dst[id.x] = src[id.x] * 10.0; +} +""") + +src_buf = create_buffer(data=[1.0, 2.0, 3.0, 4.0]) +dst_buf = create_buffer(size=16) +compute6 = create_compute(copy_shader) +compute6.set(src=src_buf, dst=dst_buf) +compute6.dispatch(1, 1, 1) +assert list(struct.unpack("<4f", dst_buf.read())) == [10.0, 20.0, 30.0, 40.0] +print("PASS") + +g.end_draw() \ No newline at end of file diff --git a/crates/processing_pyo3/examples/custom_material.py b/crates/processing_pyo3/examples/custom_material.py index e561008b..9e44fc11 100644 --- a/crates/processing_pyo3/examples/custom_material.py +++ b/crates/processing_pyo3/examples/custom_material.py @@ -7,15 +7,15 @@ def setup(): size(800, 600) mode_3d() - shader = Shader.load("shaders/custom_material.wesl") - mat = Material(shader, color=[1.0, 0.2, 0.4, 1.0]) + shader = load_shader("shaders/custom_material.wesl") + mat = create_material(shader, color=[1.0, 0.2, 0.4, 1.0]) def draw(): camera_position(0.0, 0.0, 200.0) camera_look_at(0.0, 0.0, 0.0) background(12, 12, 18) - use_material(mat) + material(mat) box(80.0, 80.0, 80.0) run() diff --git a/crates/processing_pyo3/examples/feedback.py b/crates/processing_pyo3/examples/feedback.py new file mode 100644 index 00000000..22eb040b --- /dev/null +++ b/crates/processing_pyo3/examples/feedback.py @@ -0,0 +1,56 @@ +"""Classic TouchDesigner-style feedback patch. + +The TD network is: + + source (this frame) ─┐ + ├─► Composite ─┬─► Out (display) + Feedback ─► Level ───┘ │ + ▲ (decay) │ + └───────────────────────────────┘ feedback taps the composite output + +i.e. out(t) = composite( source(t), decay * out(t-1) ). + +Here the sketch canvas *is* the composite output. Because it isn't cleared each +frame, it persists and feeds back into itself. So: + + - feedback(decay=...) is the Feedback TOP + Level (samples the previous + composite output and fades it, optionally zooming/rotating it); + - the shapes drawn afterwards are this frame's source, composited *over* the + faded feedback; + - the canvas persists, closing the loop. +""" +from mewnala import * +from math import sin, cos + +t = 0.0 + + +def setup(): + size(800, 600) + background(0, 0, 0) # clear once; draw() never clears, so the canvas accumulates + + +def draw(): + global t + + # --- feedback line: previous composite output, decayed + slowly zoomed/rotated --- + feedback(decay=0.94, zoom=1.008, angle=0.006) + + # --- this frame's source, composited over the feedback (drawn on top) --- + no_stroke() + + x = width / 2 + cos(t) * 230 + y = height / 2 + sin(t * 1.3) * 170 + fill(0, 220, 255) + circle(x, y, 44) + + x2 = width / 2 + cos(t * 0.7 + 2.0) * 150 + y2 = height / 2 + sin(t * 1.1) * 120 + fill(255, 90, 200) + circle(x2, y2, 28) + + t += 0.03 + + +# TODO: this should happen implicitly on module load somehow +run() diff --git a/crates/processing_pyo3/examples/flocking.py b/crates/processing_pyo3/examples/flocking.py new file mode 100644 index 00000000..ea87b8f7 --- /dev/null +++ b/crates/processing_pyo3/examples/flocking.py @@ -0,0 +1,209 @@ +# Flocking +# +# Ported from Dan Schiffman's Flocking +# +# An implementation of Craig Reynold's Boids program to simulate +# the flocking behavior of birds. Each boid steers itself based on +# rules of avoidance, alignment, and coherence. +# +# Click the mouse to add a new boid. +from mewnala import * + +flock = None + +title_last_time = 0.0 +title_last_frame = 0 +boid_count = 150 + +def setup(): + global flock + size(640, 360) + flock = Flock() + # Add an initial set of boids into the system + for i in range(boid_count): + flock.add_boid(Boid(width / 2, height / 2)) + + +def draw(): + global title_last_time, title_last_frame + + title_elapsed = elapsed_time - title_last_time + if title_elapsed >= 0.5: + fps = (frame_count - title_last_frame) / title_elapsed + window_title(f"Flocking — {boid_count:,} boids — {fps:.0f} FPS") + title_last_time = elapsed_time + title_last_frame = frame_count + + background(50) + flock.run() + + +# Add a new boid into the System +def mouse_pressed(): + flock.add_boid(Boid(mouse_x, mouse_y)) + + +# The Flock (a list of Boid objects) +class Flock: + def __init__(self): + self.boids = [] # A list for all the boids + + def run(self): + for b in self.boids: + b.run(self.boids) # Passing the entire list of boids to each boid individually + + def add_boid(self, b): + self.boids.append(b) + + +# The Boid class +class Boid: + def __init__(self, x, y): + self.acceleration = Vec2(0, 0) + self.velocity = Vec2.random() + self.position = Vec2(x, y) + self.r = 2.0 + self.maxspeed = 2.0 # Maximum speed + self.maxforce = 0.03 # Maximum steering force + + def run(self, boids): + self.flock(boids) + self.update() + self.borders() + self.render() + + def apply_force(self, force): + # We could add mass here if we want A = F / M + self.acceleration.add(force) + + # We accumulate a new acceleration each time based on three rules + def flock(self, boids): + sep = self.separate(boids) # Separation + ali = self.align(boids) # Alignment + coh = self.cohesion(boids) # Cohesion + # Arbitrarily weight these forces + sep.mult(1.5) + ali.mult(1.0) + coh.mult(1.0) + # Add the force vectors to acceleration + self.apply_force(sep) + self.apply_force(ali) + self.apply_force(coh) + + # Method to update position + def update(self): + # Update velocity + self.velocity.add(self.acceleration) + # Limit speed + self.velocity.limit(self.maxspeed) + self.position.add(self.velocity) + # Reset acceleration to 0 each cycle + self.acceleration.mult(0) + + # A method that calculates and applies a steering force towards a target + # STEER = DESIRED MINUS VELOCITY + def seek(self, target): + desired = target - self.position # A vector pointing from the position to the target + # Scale to maximum speed + desired.set_mag(self.maxspeed) + + # Steering = Desired minus Velocity + steer = desired - self.velocity + steer.limit(self.maxforce) # Limit to maximum steering force + return steer + + def render(self): + # Draw a triangle rotated in the direction of velocity + theta = self.velocity.heading() + HALF_PI + + fill(200, 100) + stroke(255) + push_matrix() + translate(self.position.x, self.position.y) + rotate(theta) + begin_shape(TRIANGLES) + vertex(0, -self.r * 2) + vertex(-self.r, self.r * 2) + vertex(self.r, self.r * 2) + end_shape() + pop_matrix() + + # Wraparound + def borders(self): + if self.position.x < -self.r: + self.position.x = width + self.r + if self.position.y < -self.r: + self.position.y = height + self.r + if self.position.x > width + self.r: + self.position.x = -self.r + if self.position.y > height + self.r: + self.position.y = -self.r + + # Separation + # Method checks for nearby boids and steers away + def separate(self, boids): + desired_separation = 25.0 + steer = Vec2(0, 0) + count = 0 + # For every boid in the system, check if it's too close + for other in boids: + d = self.position.dist(other.position) + # If the distance is greater than 0 and less than an arbitrary amount (0 when you are yourself) + if 0 < d < desired_separation: + # Calculate vector pointing away from neighbor + diff = (self.position - other.position).normalize() + diff.div(d) # Weight by distance + steer.add(diff) + count += 1 # Keep track of how many + # Average -- divide by how many + if count > 0: + steer.div(count) + + # As long as the vector is greater than 0 + if steer.mag() > 0: + # Implement Reynolds: Steering = Desired - Velocity + steer.set_mag(self.maxspeed) + steer.sub(self.velocity) + steer.limit(self.maxforce) + return steer + + # Alignment + # For every nearby boid in the system, calculate the average velocity + def align(self, boids): + neighbor_dist = 50.0 + sum = Vec2(0, 0) + count = 0 + for other in boids: + d = self.position.dist(other.position) + if 0 < d < neighbor_dist: + sum.add(other.velocity) + count += 1 + if count > 0: + sum.div(count) + # Implement Reynolds: Steering = Desired - Velocity + sum.set_mag(self.maxspeed) + steer = sum - self.velocity + steer.limit(self.maxforce) + return steer + else: + return Vec2(0, 0) + + # Cohesion + # For the average position (i.e. center) of all nearby boids, calculate steering vector towards that position + def cohesion(self, boids): + neighbor_dist = 50.0 + sum = Vec2(0, 0) # Start with empty vector to accumulate all positions + count = 0 + for other in boids: + d = self.position.dist(other.position) + if 0 < d < neighbor_dist: + sum.add(other.position) # Add position + count += 1 + if count > 0: + sum.div(count) + return self.seek(sum) # Steer towards the position + else: + return Vec2(0, 0) + + +run() diff --git a/crates/processing_pyo3/examples/flocking_duck.py b/crates/processing_pyo3/examples/flocking_duck.py new file mode 100644 index 00000000..3af396ab --- /dev/null +++ b/crates/processing_pyo3/examples/flocking_duck.py @@ -0,0 +1,144 @@ +# Flocking inside a duck: the GPU boids from flocking_gpu.py, seeded from +# the vertices of the Duck glTF mesh. Each boid remembers its spawn vertex +# in a `home` attribute; a homing force that is negligible near home but +# grows quadratically with distance lets the boids swirl and flock locally +# while the swarm as a whole never loses the duck's shape. +from mewnala import * +from math import cos, sin +from random import uniform + +DT = 1.0 / 60.0 + +# Pass 1: Reynolds' three rules plus the tether. Every boid reads the whole +# flock's state and writes only its steering force, so no boid ever sees a +# half-updated neighbor. + +# Pass 2: integrate the steering force and point each instanced boid along +# its velocity. No wrapping — the tether is the only containment needed. + +p = None +boid = None +mat = None +flock_pass = None +integrate_pass = None +center = None +extent = 0.0 +max_speed = 0.0 +boid_count = 0 +title_last_time = 0.0 +title_last_frame = 0 + + +# Two triangles folded slightly along the nose-tail spine, like a paper +# boid pointing down +Z. The fold keeps the boid visible edge-on and gives +# each wing its own normal, so the flock glints as it banks. +def boid_geometry(half_width, length, droop): + g = create_geometry() + n = (half_width * half_width + droop * droop) ** 0.5 + nose = (0.0, 0.0, length * 0.5) + tail = (0.0, 0.0, -length * 0.5) + g.normal(-droop / n, half_width / n, 0.0) + g.vertex(*nose) + g.vertex(-half_width, -droop, -length * 0.5) + g.vertex(*tail) + g.normal(droop / n, half_width / n, 0.0) + g.vertex(*nose) + g.vertex(*tail) + g.vertex(half_width, -droop, -length * 0.5) + for i in range(6): + g.index(i) + return g + + +def setup(): + global p, boid, mat, flock_pass, integrate_pass, center, extent, max_speed, boid_count + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 800.0) + + gltf = load_gltf("gltf/Duck.glb") + duck = gltf.geometry("LOD3spShape") + + p = create_particles( + geometry=duck, + attributes=[ + Attribute.position(), + Attribute.rotation(), + Attribute.color(), + Attribute.velocity(), + Attribute("home", AttributeFormat.Float3), + Attribute("steer", AttributeFormat.Float3), + ], + ) + + # The duck's vertices become the boids' homes. Every tuning constant is + # derived from the mesh's bounding box, so the sketch doesn't care what + # units the model was authored in. + homes = p.buffer("position").read() + boid_count = len(homes) + window_title(f"GPU Flocking Duck — {boid_count:,} boids") + lo = [min(v[i] for v in homes) for i in range(3)] + hi = [max(v[i] for v in homes) for i in range(3)] + center = [(lo[i] + hi[i]) * 0.5 for i in range(3)] + extent = sum((hi[i] - lo[i]) ** 2 for i in range(3)) ** 0.5 + max_speed = 0.15 * extent + + p.buffer("home").write(homes) + + velocities = [] + rotations = [] + colors = [] + for _ in homes: + velocities.append([uniform(-1.0, 1.0) * max_speed * 0.4 for _ in range(3)]) + rotations.append([0.0, 0.0, 0.0, 1.0]) + c = hsva(uniform(38.0, 58.0), 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + + p.buffer("velocity").write(velocities) + p.buffer("rotation").write(rotations) + color_buf = p.buffer("color") + color_buf.write(colors) + + s = 0.008 * extent + boid = boid_geometry(1.2 * s, 3.5 * s, 0.4 * s) + mat = create_material(albedo=color_buf) + + flock_pass = create_compute(load_shader("shaders/flocking_duck_flock.wesl")) + integrate_pass = create_compute(load_shader("shaders/flocking_duck_integrate.wesl")) + + +def draw(): + global title_last_time, title_last_frame + + title_elapsed = elapsed_time - title_last_time + if title_elapsed >= 0.5: + fps = (frame_count - title_last_frame) / title_elapsed + window_title(f"GPU Flocking Duck — {boid_count:,} boids — {fps:.0f} FPS") + title_last_time = elapsed_time + title_last_frame = frame_count + + t = elapsed_time * 0.2 + r = extent * 1.1 + camera_position(center[0] + cos(t) * r, center[1] + extent * 0.35, center[2] + sin(t) * r) + camera_look_at(center[0], center[1], center[2]) + background(10, 12, 18) + + material(mat) + particles(p, boid) + + flock_pass.set( + neighbor_dist=0.06 * extent, + separation_dist=0.03 * extent, + max_speed=max_speed, + max_force=2.0 * max_speed, + home_radius=0.04 * extent, + ) + p.apply(flock_pass) + + integrate_pass.set(dt=DT, max_speed=max_speed) + p.apply(integrate_pass) + + +run() diff --git a/crates/processing_pyo3/examples/flocking_gpu.py b/crates/processing_pyo3/examples/flocking_gpu.py new file mode 100644 index 00000000..d3119830 --- /dev/null +++ b/crates/processing_pyo3/examples/flocking_gpu.py @@ -0,0 +1,136 @@ +# GPU flocking: the boids from flocking.py, moved entirely onto the GPU. +# Positions and velocities live in particle attribute buffers, two compute +# kernels update them each frame, and the flock renders instanced — nothing +# is ever read back to the CPU. Brute-force O(N²) neighbor search is trivial +# for a GPU at this scale; a spatial hash grid is the next step past ~100k. +from mewnala import * +from math import cos, sin +from random import uniform + +BOID_COUNT = 10000 +BOUND = 30.0 # half-extent of the wrapping box +NEIGHBOR_DIST = 5.0 +SEPARATION_DIST = 2.5 +MAX_SPEED = 10.0 # units per second +MAX_FORCE = 6.0 # units per second² +DT = 1.0 / 60.0 + +# Pass 1: every boid reads the whole flock's state and writes only its +# steering force. Splitting the read from the write mirrors the CPU +# example's two loops — no boid sees a half-updated neighbor. + +# Pass 2: integrate the steering force, wrap at the box edges, and point +# each instanced boid along its velocity via the rotation quaternion. + +p = None +boid = None +mat = None +grid = None +title_last_time = 0.0 +title_last_frame = 0 + + +# Two triangles folded slightly along the nose-tail spine, like a paper +# boid pointing down +Z. The fold keeps the boid visible edge-on and gives +# each wing its own normal, so the flock glints as it banks. +def boid_geometry(half_width, length, droop): + g = create_geometry() + n = (half_width * half_width + droop * droop) ** 0.5 + nose = (0.0, 0.0, length * 0.5) + tail = (0.0, 0.0, -length * 0.5) + g.normal(-droop / n, half_width / n, 0.0) + g.vertex(*nose) + g.vertex(-half_width, -droop, -length * 0.5) + g.vertex(*tail) + g.normal(droop / n, half_width / n, 0.0) + g.vertex(*nose) + g.vertex(*tail) + g.vertex(half_width, -droop, -length * 0.5) + for i in range(6): + g.index(i) + return g + + +def setup(): + global p, boid, mat, grid + + size(900, 700) + window_title(f"GPU Flocking — {BOID_COUNT:,} boids") + mode_3d() + + directional_light((0.95, 0.9, 0.85), 800.0) + + p = create_particles( + capacity=BOID_COUNT, + attributes=[ + Attribute.position(), + Attribute.rotation(), + Attribute.color(), + Attribute.velocity(), + ], + ) + + positions = [] + velocities = [] + rotations = [] + colors = [] + for _ in range(BOID_COUNT): + positions.append([uniform(-BOUND, BOUND) for _ in range(3)]) + velocities.append([uniform(-1.0, 1.0) * MAX_SPEED * 0.4 for _ in range(3)]) + rotations.append([0.0, 0.0, 0.0, 1.0]) + c = hsva(uniform(190.0, 280.0), 0.7, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + + p.buffer("position").write(positions) + p.buffer("rotation").write(rotations) + p.buffer("velocity").write(velocities) + color_buf = p.buffer("color") + color_buf.write(colors) + + boid = boid_geometry(0.4, 1.3, 0.15) + mat = create_material(albedo=color_buf) + + cells = int((2.0 * BOUND) / NEIGHBOR_DIST) + 1 + grid = p.create_grid( + min=[-BOUND, -BOUND, -BOUND], + cell_size=NEIGHBOR_DIST, + dims=[cells, cells, cells], + ) + + +def draw(): + global title_last_time, title_last_frame + + title_elapsed = elapsed_time - title_last_time + if title_elapsed >= 0.5: + fps = (frame_count - title_last_frame) / title_elapsed + window_title(f"GPU Flocking — {BOID_COUNT:,} boids — {fps:.0f} FPS") + title_last_time = elapsed_time + title_last_frame = frame_count + + t = elapsed_time * 0.1 + r = BOUND * 2.6 + camera_position(cos(t) * r, BOUND * 0.8, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + background(10, 12, 18) + + material(mat) + particles(p, boid) + + p.flock( + grid, + sep_distance=SEPARATION_DIST, + neighbor_distance=NEIGHBOR_DIST, + weight_separation=1.5, + weight_alignment=1.0, + weight_cohesion=1.0, + max_speed=MAX_SPEED, + max_force=MAX_FORCE * DT, + min_speed=MAX_SPEED * 0.25, + ) + p.apply(INTEGRATE, dt=DT) + p.apply(BOUNDS_BOX, aabb_min=[-BOUND] * 3, aabb_max=[BOUND] * 3, mode=2) + p.apply(ORIENT, forward=[0.0, 0.0, 1.0], up=[0.0, 1.0, 0.0]) + + +run() diff --git a/crates/processing_pyo3/examples/geometry_methods.py b/crates/processing_pyo3/examples/geometry_methods.py index 5f864554..c281a4ed 100644 --- a/crates/processing_pyo3/examples/geometry_methods.py +++ b/crates/processing_pyo3/examples/geometry_methods.py @@ -8,7 +8,7 @@ def setup(): size(640, 480) mode_3d() - geometry = Geometry() + geometry = create_geometry() geometry.normal(0.0, 0.0, 1.0) diff --git a/crates/processing_pyo3/examples/gltf_load.py b/crates/processing_pyo3/examples/gltf_load.py index dcdea0f6..b96d16f9 100644 --- a/crates/processing_pyo3/examples/gltf_load.py +++ b/crates/processing_pyo3/examples/gltf_load.py @@ -1,5 +1,5 @@ -import math from mewnala import * +import math gltf = None duck_geo = None @@ -36,7 +36,7 @@ def draw(): duck_mat.set(base_color=[r, g, b, 1.0]) background(25) - use_material(duck_mat) + material(duck_mat) draw_geometry(duck_geo) frame += 1 diff --git a/crates/processing_pyo3/examples/lights.py b/crates/processing_pyo3/examples/lights.py index e825893a..f5d4d795 100644 --- a/crates/processing_pyo3/examples/lights.py +++ b/crates/processing_pyo3/examples/lights.py @@ -7,21 +7,21 @@ def setup(): mode_3d() # Directional Light - dir_light = create_directional_light((0.5, 0.24, 1.0), 1500.0) + dir_light = directional_light((0.5, 0.24, 1.0), 1500.0) # Point Lights - point_light_a = create_point_light((1.0, 0.5, 0.25), 1000000.0, 200.0, 0.5) + point_light_a = point_light((1.0, 0.5, 0.25), 1000000.0, 200.0, 0.5) point_light_a.position(-25.0, 5.0, 51.0) point_light_a.look_at(0.0, 0.0, 0.0) - point_light_b = create_point_light((0.0, 0.5, 0.75), 2000000.0, 200.0, 0.25) + point_light_b = point_light((0.0, 0.5, 0.75), 2000000.0, 200.0, 0.25) point_light_b.position(0.0, 5.0, 50.5) point_light_b.look_at(0.0, 0.0, 0.0) # Spot Light - spot_light = create_spot_light((0.25, 0.8, 0.19), 15.0 * 1000000.0, 200.0, 0.84, 0.0, 0.7854) - spot_light.position(40.0, 0.0, 70.0) - spot_light.look_at(0.0, 0.0, 0.0) + spot_light_a = spot_light((0.25, 0.8, 0.19), 15.0 * 1000000.0, 200.0, 0.84, 0.0, 0.7854) + spot_light_a.position(40.0, 0.0, 70.0) + spot_light_a.look_at(0.0, 0.0, 0.0) def draw(): global angle diff --git a/crates/processing_pyo3/examples/materials.py b/crates/processing_pyo3/examples/materials.py index ffa66cc5..1924fb59 100644 --- a/crates/processing_pyo3/examples/materials.py +++ b/crates/processing_pyo3/examples/materials.py @@ -7,21 +7,22 @@ def setup(): size(800, 600) mode_3d() - dir_light = create_directional_light((1.0, 0.98, 0.95), 1500.0) - point_light = create_point_light((1.0, 1.0, 1.0), 100000.0, 800.0, 0.0) - point_light.position(200.0, 200.0, 400.0) + dir_light = directional_light((1.0, 0.98, 0.95), 1500.0) + p_light = point_light((1.0, 1.0, 1.0), 100000.0, 800.0, 0.0) + p_light.position(200.0, 200.0, 400.0) - mat = Material() - mat.set(roughness=0.3) - mat.set(metallic=0.8) - mat.set(base_color=[1.0, 0.85, 0.57, 1.0]) + mat = create_material( + roughness=0.3, + metallic=0.8, + base_color=[1.0, 0.85, 0.57, 1.0], + ) def draw(): camera_position(0.0, 0.0, 200.0) camera_look_at(0.0, 0.0, 0.0) background(12, 12, 18) - use_material(mat) + material(mat) sphere(50.0) run() diff --git a/crates/processing_pyo3/examples/multi_window.py b/crates/processing_pyo3/examples/multi_window.py new file mode 100644 index 00000000..92316716 --- /dev/null +++ b/crates/processing_pyo3/examples/multi_window.py @@ -0,0 +1,50 @@ +from mewnala import * +from math import sin, cos, pi + +second_window = None +N = 7 + +def points(w, h, t): + pts = [] + for i in range(N): + angle = t * (0.3 + i * 0.05) + i * (2 * pi / N) + radius = min(w, h) * (0.18 + 0.12 * sin(t * 0.7 + i)) + x = w / 2 + cos(angle) * radius + y = h / 2 + sin(angle * 1.3) * radius + pts.append((x, y)) + return pts + + +def setup(): + global second_window + size(480, 480) + second_window = create_window(360, 360, "Second window") + + +def draw(): + t = frame_count * 0.03 + + background(20, 16, 28) + no_stroke() + for i, (x, y) in enumerate(points(width, height, t)): + fill(255, 120 + i * 15, 80) + circle(x, y, 26) + + g = second_window + g.background(10, 12, 24) + g.stroke(120, 180, 255) + g.stroke_weight(1.5) + pts = points(g.width, g.height, t) + link = (g.width * 0.35) ** 2 + for i in range(N): + for j in range(i + 1, N): + (x1, y1), (x2, y2) = pts[i], pts[j] + if (x1 - x2) ** 2 + (y1 - y2) ** 2 < link: + g.line(x1, y1, x2, y2) + g.no_stroke() + g.fill(220, 235, 255) + for (x, y) in pts: + g.circle(x, y, 6) + + +run() diff --git a/crates/processing_pyo3/examples/particles_animated.py b/crates/processing_pyo3/examples/particles_animated.py new file mode 100644 index 00000000..b80c9335 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -0,0 +1,47 @@ +from mewnala import * + +p = None +sphere = None +mat = None +spin = None + + +def setup(): + global p, sphere, mat, spin + + size(900, 700) + mode_3d() + + directional_light((0.9, 0.85, 0.8), 300.0) + + sphere = Geometry.sphere(0.25, 12, 8) + + capacity = 1000 + positions = [] + for x in range(10): + for y in range(10): + for z in range(10): + positions.extend([x - 4.5, y - 4.5, z - 4.5]) + + p = create_particles(capacity=capacity, attributes=[Attribute.position()]) + pos_buf = p.buffer("position") + pos_buf.write(positions) + + mat = create_material(roughness=0.4) + spin = create_compute(load_shader("shaders/particles_animated_spin.wesl")) + + +def draw(): + camera_position(0.0, 8.0, 25.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + fill(230, 128, 75) + + material(mat) + particles(p, sphere) + + spin.set(dt=0.01) + p.apply(spin) + + +run() diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py new file mode 100644 index 00000000..ae897b85 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -0,0 +1,44 @@ +from mewnala import * + +p = None +particle = None +mat = None + + +def setup(): + global p, particle, mat + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 600.0) + + source = Geometry.sphere(5.0, 32, 24) + p = create_particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = create_material(albedo=color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + material(mat) + particles(p, particle) + + +run() diff --git a/crates/processing_pyo3/examples/particles_density.py b/crates/processing_pyo3/examples/particles_density.py new file mode 100644 index 00000000..1bbe6463 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_density.py @@ -0,0 +1,54 @@ +from mewnala import * +from random import uniform +from math import cos, sin + +N = 50000 +BOX = 20.0 +RADIUS = 1.5 + +p = None +g = None +tint = None + + +def setup(): + global p, g, tint + size(900, 700) + window_title(f"Neighbour density — {N:,}") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute("density", AttributeFormat.Float), + ], + ) + p.buffer("position").write([[uniform(-BOX, BOX) for _ in range(3)] for _ in range(N)]) + + cells = int(2.0 * BOX / RADIUS) + 2 + g = p.create_grid(min=[-BOX, -BOX, -BOX], cell_size=RADIUS, dims=[cells, cells, cells]) + + tint = create_compute(load_shader("shaders/density_color.wesl")) + + +def draw(): + background(3, 4, 9) + + t = elapsed_time + d = BOX * 2.4 + camera_position(cos(t * 0.08) * d, BOX * 0.5, sin(t * 0.08) * d) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(NOISE, scale=0.12, strength=0.06, time=t * 0.15, divergence_free=1) + p.apply(BOUNDS_BOX, aabb_min=[-BOX] * 3, aabb_max=[BOX] * 3, mode=2) + + p.apply(NEIGHBOR, grid=g, out="density", op=DENSITY, radius=RADIUS) + tint.set(scale=1.0 / 30.0) + p.apply(tint) + + particles(p) + + +run() diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py new file mode 100644 index 00000000..4a3db9dc --- /dev/null +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -0,0 +1,56 @@ +from mewnala import * +import math + +p = None +sphere = None +mat = None +frame = 0 + + +def setup(): + global p, sphere, mat + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.08, 8, 6) + + capacity = 2000 + p = create_particles( + capacity=capacity, + attributes=[Attribute.position(), Attribute.color()], + ) + + pos_buf = p.buffer("position") + pos_buf.write([1.0e6] * (capacity * 3)) + + color_buf = p.buffer("color") + mat = create_material(unlit=True, albedo=color_buf) + + +def draw(): + global frame + camera_position(0.0, 4.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + material(mat) + particles(p, sphere) + + burst = 4 + positions = [] + colors = [] + for k in range(burst): + i = frame * burst + k + t = i * 0.05 + radius = 1.5 + min(t * 0.02, 3.0) + height = math.sin(t * 0.1) * 2.0 + positions.extend([math.cos(t) * radius, height, math.sin(t) * radius]) + c = hsva((i * 4.32) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + p.emit(burst, position=positions, color=colors) + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py new file mode 100644 index 00000000..22093ad3 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -0,0 +1,67 @@ +from mewnala import * +import math + +p = None +particle = None +mat = None +spawn = None +motion = None + +CAPACITY = 40000 +BURST = 120 +DT = 1.0 / 60.0 +TTL = 2.5 +GRAVITY = 9.8 +SPEED = 5.0 + + +def setup(): + global p, particle, mat, spawn, motion + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 800.0) + + particle = Geometry.sphere(0.12, 8, 6) + + # Attributes a compute shader binds must exist when its bind group is built, + # so declare them (all built-in here). `velocity`/`age` are built-ins now, so + # no custom `Attribute(...)` is needed. + p = create_particles( + capacity=CAPACITY, + attributes=[ + Attribute.position(), + Attribute.velocity(), + Attribute.color(), + Attribute.scale(), + Attribute.age(), + Attribute.life(), + ], + ) + + mat = create_material(albedo=p.buffer("color")) + + spawn = create_compute(load_shader("shaders/particles_emit_gpu_spawn.wesl")) + motion = create_compute(load_shader("shaders/particles_emit_gpu_motion.wesl")) + + +def draw(): + camera_position(0.0, 4.0, 16.0) + camera_look_at(0.0, 2.0, 0.0) + background(10, 10, 18) + + material(mat) + particles(p, particle) + + t = elapsed_time + sx = math.cos(t) * 0.4 + sz = math.sin(t) * 0.4 + spawn.set(pos=[sx, 7.0, sz, 0.0], speed=[SPEED, 0.0, 0.0, 0.0]) + p.emit_gpu(BURST, spawn) + + motion.set(dt=DT, ttl=TTL, gravity=GRAVITY) + p.apply(motion) + + +run() diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py new file mode 100644 index 00000000..f7e5d64a --- /dev/null +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -0,0 +1,44 @@ +from mewnala import * + +p = None +particle = None +mat = None + + +def setup(): + global p, particle, mat + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 200.0) + + source = Geometry.sphere(5.0, 32, 24) + p = create_particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = create_material(albedo=color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + material(mat) + particles(p, particle) + + +run() diff --git a/crates/processing_pyo3/examples/particles_gpu_surface.py b/crates/processing_pyo3/examples/particles_gpu_surface.py new file mode 100644 index 00000000..e0e4af69 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_gpu_surface.py @@ -0,0 +1,37 @@ +from mewnala import * +from math import cos, sin + +NX, NY = 160, 160 +EXTENT = 24.0 + +p = None +gen = None + + +def setup(): + global p, gen + size(900, 700) + window_title("gpu index") + mode_3d() + + p = create_particles(capacity=NX * NY, attributes=[Attribute.position()]) + idx = p.index_buffer((NX - 1) * (NY - 1) * 6) + gen = create_compute(load_shader("shaders/gen_surface.wesl")) + gen.set(indices=idx) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + d = EXTENT * 1.4 + camera_position(cos(t * 0.1) * d, EXTENT * 0.7, sin(t * 0.1) * d) + camera_look_at(0.0, 0.0, 0.0) + + gen.set(nx=NX, ny=NY, time=t, extent=EXTENT) + p.apply(gen) + + particles(p, topology=TRIANGLES) + + +run() diff --git a/crates/processing_pyo3/examples/particles_gpu_surface_lit.py b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py new file mode 100644 index 00000000..d737d1bd --- /dev/null +++ b/crates/processing_pyo3/examples/particles_gpu_surface_lit.py @@ -0,0 +1,40 @@ +from mewnala import * +from math import cos, sin + +NX, NY = 200, 200 +EXTENT = 30.0 + +p = None +gen = None + + +def setup(): + global p, gen + size(900, 700) + window_title("GPU surface — color + normals generated on the GPU") + mode_3d() + + p = create_particles( + capacity=NX * NY, + attributes=[Attribute.position(), Attribute.color(), Attribute.normal()], + ) + idx = p.index_buffer((NX - 1) * (NY - 1) * 6) + gen = create_compute(load_shader("shaders/gen_colored_surface.wesl")) + gen.set(indices=idx) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + d = EXTENT * 1.3 + camera_position(cos(t * 0.08) * d, EXTENT * 0.6, sin(t * 0.08) * d) + camera_look_at(0.0, 0.0, 0.0) + + gen.set(nx=NX, ny=NY, time=t, extent=EXTENT) + p.apply(gen) + + particles(p, topology=TRIANGLES) + + +run() diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py new file mode 100644 index 00000000..d6ecdd2e --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -0,0 +1,78 @@ +from mewnala import * +import math + +p = None +sphere = None +mat = None +aging = None +frame = 0 + +BURST = 6 +DT = 1.0 / 60.0 +TTL = 1.0 + + +def setup(): + global p, sphere, mat, aging + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.1, 8, 6) + + capacity = 800 + p = create_particles( + capacity=capacity, + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute.scale(), + Attribute.life(), + Attribute.age(), + ], + ) + color_buf = p.buffer("color") + mat = create_material(unlit=True, albedo=color_buf) + aging = create_compute(load_shader("shaders/particles_lifecycle_aging.wesl")) + + +def draw(): + global frame + camera_position(0.0, 2.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(10, 10, 18) + + material(mat) + particles(p, sphere) + + positions = [] + colors = [] + for k in range(BURST): + i = frame * BURST + k + u = (((i * 2654435761) >> 8) & 0xFFFF) / 65535.0 + v = (((i * 40503) >> 8) & 0xFFFF) / 65535.0 + theta = u * math.tau + r = v * 0.6 + positions.extend([math.cos(theta) * r, 2.5, math.sin(theta) * r]) + c = hsva((i * 4.68) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + zeros = [0.0] * BURST + ones = [1.0] * BURST + ones_scale = [1.0] * (BURST * 3) + p.emit( + BURST, + position=positions, + color=colors, + scale=ones_scale, + age=zeros, + life=ones, + ) + + aging.set(params=[DT, TTL, 0.0, 0.0]) + p.apply(aging) + + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/particles_lines.py b/crates/processing_pyo3/examples/particles_lines.py new file mode 100644 index 00000000..3137ae11 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lines.py @@ -0,0 +1,47 @@ +from mewnala import * +from math import cos, sin, tau + +N = 3000 +P, Q = 3, 2 +SCALE = 8.0 + +p = None + + +def knot(t, phase): + r = cos(Q * t) + 2.0 + x = r * cos(P * t + phase) + y = r * sin(P * t + phase) + z = -sin(Q * t) + return (x * SCALE, y * SCALE, z * SCALE) + + +def setup(): + global p + size(900, 700) + window_title(f"Particle lines — {N:,}-point torus knot") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[Attribute.position()], + ) + p.buffer("position").write([list(knot(i / N * tau, 0.0)) for i in range(N)]) + + +def draw(): + background(6, 8, 14) + + phase = elapsed_time * 0.4 + positions = [list(knot(i / N * tau, phase)) for i in range(N)] + p.buffer("position").write(positions) + + t = elapsed_time * 0.12 + r = SCALE * 4.5 + camera_position(cos(t) * r, SCALE * 1.5, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p, topology="line_strip") + + +run() diff --git a/crates/processing_pyo3/examples/particles_lissajous.py b/crates/processing_pyo3/examples/particles_lissajous.py new file mode 100644 index 00000000..6bfa291c --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lissajous.py @@ -0,0 +1,85 @@ +from mewnala import * +from math import cos, sin + +ALPHA_OVER = BlendMode( + color_src=BlendMode.SRC_ALPHA, + color_dst=BlendMode.ONE_MINUS_SRC_ALPHA, + color_op=BlendMode.OP_ADD, + alpha_src=BlendMode.ONE, + alpha_dst=BlendMode.ONE_MINUS_SRC_ALPHA, + alpha_op=BlendMode.OP_ADD, +) + +N = 10000 # points on the curve +SCALE = 10.0 +FX, FY, FZ = 3.0, 4.0, 5.0 + +CONNECTION_RADIUS = 3.5 # link points closer than this +CONNECTION_RAMP = 7.0 # alpha = (1/(d/R + 1))^ramp +LINE_ALPHA = 0.2 # overall opacity scale +MAX_LINKS = 4000 # per-point edge cap (bounds the edge buffer) +HUE_MIX = 0.0 # 0 = grayscale, 1 = rainbow + +p = None # curve points (source) +edges = None # emitted line vertices (drawn) +curve = None +link = None +grid = None +idx = None + + +def setup(): + global p, edges, curve, link, grid, idx + size(1000, 800) + window_title(f"Lissajous — all points connected — {N:,} pts") + mode_3d() + bloom(0.0) + + p = create_particles(capacity=N, attributes=[Attribute.position(), Attribute.color()]) + # One line = 2 vertices; up to N*MAX_LINKS lines. + edges = create_particles( + capacity=N * MAX_LINKS * 2, attributes=[Attribute.position(), Attribute.color()] + ) + idx = edges.index_buffer(N * MAX_LINKS * 2) # link fills indices + the dynamic count + + cells = int((2.0 * SCALE + 2.0) / CONNECTION_RADIUS) + 1 + grid = p.create_grid( + min=[-SCALE - 1.0] * 3, cell_size=CONNECTION_RADIUS, dims=[cells, cells, cells] + ) + + curve = create_compute(load_shader("shaders/plexus_curve.wesl")) + link = create_compute(load_shader("shaders/plexus_link.wesl")) + + +def draw(): + bloom(0.0) # re-assert each frame + background(255, 255, 255) + + t = elapsed_time + r = 26.0 + camera_position(cos(t * 0.08) * r, sin(t * 0.05) * r * 0.55, sin(t * 0.08) * r) + camera_look_at(0.0, 0.0, 0.0) + + curve.set(count=N, time=t, fx=FX, fy=FY, fz=FZ, loops=1.0, scale=SCALE, hue_mix=HUE_MIX) + p.apply(curve) + + grid.build(p.buffer("position")) + edges.reset_indices() + link.set( + edge_pos=edges.buffer("position"), + edge_col=edges.buffer("color"), + indices=idx, + draw_args=edges.draw_args(), + connection_radius=CONNECTION_RADIUS, + connection_ramp=CONNECTION_RAMP, + line_alpha=LINE_ALPHA, + max_links=MAX_LINKS, + ) + grid.bind(link) + p.apply(link) + + blend_mode(ALPHA_OVER) + particles(edges, topology="lines") + + +run() diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py new file mode 100644 index 00000000..ba2aa993 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -0,0 +1,49 @@ +from mewnala import * + +p = None +particle = None +mat = None +noise = None + + +def setup(): + global p, particle, mat, noise + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 200.0) + + source = Geometry.sphere(5.0, 32, 24) + p = create_particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer("uv") + color_buf = p.buffer("color") + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = create_material(albedo=color_buf) + noise = Particles.noise() + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + material(mat) + particles(p, particle) + + noise.set(scale=0.25, strength=0.02, time=elapsed_time * 0.5) + p.apply(noise) + + +run() diff --git a/crates/processing_pyo3/examples/particles_plexus.py b/crates/processing_pyo3/examples/particles_plexus.py new file mode 100644 index 00000000..00ad6fd9 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_plexus.py @@ -0,0 +1,74 @@ +from mewnala import * +from random import uniform, seed +from math import cos, sin + +N = 2500 # particles +BOX = 12.0 # half-extent of the box +LINK_DIST = 1.8 +MAX_LINKS = 8 # per-particle edge cap +SPEED = 3.0 # units/sec +DT = 1.0 / 60.0 + +BOUNCE = 1 # bounds_box mode: 0=clamp, 1=reflect(bounce), 2=wrap + +p = None +link = None +grid = None +idx = None +args = None + + +def setup(): + global p, link, grid, idx, args + size(1000, 760) + window_title(f"Plexus — {N:,} particles, dynamic GPU links") + mode_3d() + + p = create_particles( + capacity=N, + attributes=[Attribute.position(), Attribute.velocity(), Attribute.color()], + ) + + seed(7) + positions, velocities, colors = [], [], [] + for _ in range(N): + positions.append([uniform(-BOX, BOX) for _ in range(3)]) + velocities.append([uniform(-1.0, 1.0) * SPEED for _ in range(3)]) + c = hsva(uniform(170.0, 320.0), 0.65, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + p.buffer("position").write(positions) + p.buffer("velocity").write(velocities) + p.buffer("color").write(colors) + + # sized for the worst case: every particle at its cap (2 indices per edge) + idx = p.index_buffer(N * MAX_LINKS * 2) + args = p.draw_args() + + # cell_size = LINK_DIST so the 27-cell neighbour walk covers the search radius + cells = int((2.0 * BOX) / LINK_DIST) + 1 + grid = p.create_grid(min=[-BOX, -BOX, -BOX], cell_size=LINK_DIST, dims=[cells, cells, cells]) + + link = create_compute(load_shader("shaders/plexus_link.wesl")) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time * 0.12 + r = BOX * 3.0 + camera_position(cos(t) * r, BOX * 0.9, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(INTEGRATE, dt=DT) + p.apply(BOUNDS_BOX, aabb_min=[-BOX] * 3, aabb_max=[BOX] * 3, mode=BOUNCE, max_speed=SPEED) + + grid.build(p.buffer("position")) + p.reset_indices() + link.set(indices=idx, draw_args=args, link_distance=LINK_DIST, max_links=MAX_LINKS) + grid.bind(link) + p.apply(link) + + particles(p, topology="lines") + + +run() diff --git a/crates/processing_pyo3/examples/particles_points.py b/crates/processing_pyo3/examples/particles_points.py new file mode 100644 index 00000000..2c745d27 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_points.py @@ -0,0 +1,37 @@ +from mewnala import * +from math import cos, sin +from random import uniform + +COUNT = 20000 +BOUND = 20.0 + +p = None + + +def setup(): + global p + size(900, 700) + window_title(f"Particle points — {COUNT:,}") + mode_3d() + + p = create_particles( + capacity=COUNT, + attributes=[Attribute.position()], + ) + + positions = [[uniform(-BOUND, BOUND) for _ in range(3)] for _ in range(COUNT)] + p.buffer("position").write(positions) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time * 0.15 + r = BOUND * 2.6 + camera_position(cos(t) * r, BOUND * 0.6, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p) + + +run() diff --git a/crates/processing_pyo3/examples/particles_scatter_volume.py b/crates/processing_pyo3/examples/particles_scatter_volume.py new file mode 100644 index 00000000..af3d251d --- /dev/null +++ b/crates/processing_pyo3/examples/particles_scatter_volume.py @@ -0,0 +1,51 @@ +from mewnala import * + +CAPACITY = 30_000 +BURST = 250 + +p = None +particle = None +mat = None +scatter = None + + +def setup(): + global p, particle, mat, scatter + + size(900, 700) + mode_3d() + + camera_position(0.0, 100.0, 400.0) + camera_look_at(0.0, 80.0, 0.0) + orbit_camera() + + gltf = load_gltf("gltf/Duck.glb") + duck = gltf.geometry("LOD3spShape") + scatter = Particles.scatter_volume(duck) + + particle = Geometry.sphere(0.15, 4, 3) + + p = create_particles( + capacity=CAPACITY, + attributes=[ + Attribute.position(), + Attribute.scale(), + Attribute.life(), + Attribute.age(), + ], + ) + mat = create_material(unlit=True, albedo=[1.0, 1.0, 1.0, 1.0]) + + +def draw(): + background(8, 8, 13) + material(mat) + particles(p, particle) + + seed = (int(elapsed_time * 1000.0) ^ 0xC0FFEE) & 0xFFFFFFFF + scatter.set(seed=seed) + p.emit_gpu(BURST, scatter) + p.apply(MAP, a="scale", op=AFFINE, scale=0.985, offset=0.0) + + +run() diff --git a/crates/processing_pyo3/examples/particles_sphere.py b/crates/processing_pyo3/examples/particles_sphere.py new file mode 100644 index 00000000..e8bd6927 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_sphere.py @@ -0,0 +1,39 @@ +from mewnala import * +from math import cos, sin + +from mewnala.mewnala import TRIANGLES + +p = None +rest = None + + +def setup(): + global p + size(900, 700) + window_title("Sphere") + mode_3d() + + rest = Attribute("rest", AttributeFormat.Float3) + sphere = Geometry.sphere(1.5, 96, 64) + p = create_particles( + geometry=sphere, + attributes=[ + Attribute.position(), + rest, + ], + ) + p.apply(MAP, a=Attribute.position(), out=rest, op=AFFINE, scale=1.0, offset=0.0) + + +def draw(): + background(6, 8, 14) + + t = elapsed_time + r = 5.0 + camera_position(cos(t * 0.15) * r, 1.6, sin(t * 0.15) * r) + camera_look_at(0.0, 0.0, 0.0) + + p.apply(NOISE, scale=0.9, strength=0.01, time=t * 0.3, divergence_free=1) + particles(p, topology=TRIANGLES) + +run() diff --git a/crates/processing_pyo3/examples/particles_stress.py b/crates/processing_pyo3/examples/particles_stress.py new file mode 100644 index 00000000..b86a3ec4 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_stress.py @@ -0,0 +1,51 @@ +from mewnala import * + +GRID = 150 +SPACING = 1.0 +SPIN_PER_FRAME = 0.003 + +p = None +cube = None +spin = None + + +def setup(): + global p, cube, spin + + size(900, 700) + mode_3d() + + extent = GRID * SPACING * 0.5 + camera_position(0.0, extent * 0.6, extent * 2.5) + camera_look_at(0.0, 0.0, 0.0) + orbit_camera() + + directional_light((1.0, 0.0, 0.0), 1000.0, position=Vec3.X, look_at=Vec3.ZERO) + directional_light((0.0, 1.0, 0.0), 1000.0, position=Vec3.Y, look_at=Vec3.ZERO) + directional_light((0.0, 0.0, 1.0), 1000.0, position=Vec3.Z, look_at=Vec3.ZERO) + + p = create_particles( + geometry=Geometry.grid(GRID, GRID, GRID, SPACING), + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + p.apply(Particles.noise(), scale=1.0 / SPACING, strength=SPACING * 0.6) + + color_buf = p.buffer("color") + color_buf.write([ + [c.r, c.g, c.b, 1.0] + for uv in p.buffer("uv").read() + for c in [hsva(uv[0] * 360.0, 0.85, 1.0)] + ]) + + fill(color_buf) + cube = Geometry.box(0.35, 0.35, 0.35) + spin = Particles.transform() + + +def draw(): + background(10, 10, 18) + particles(p, cube) + p.apply(spin, rotation_axis=Vec3.Y, rotation_angle=SPIN_PER_FRAME) + +run() diff --git a/crates/processing_pyo3/examples/particles_surface.py b/crates/processing_pyo3/examples/particles_surface.py new file mode 100644 index 00000000..07afe584 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_surface.py @@ -0,0 +1,55 @@ +from mewnala import * +from math import cos, sin, tau + +SEGMENTS = 400 +N = SEGMENTS * 2 +R = 10.0 +WIDTH = 3.5 + +p = None + + +def band(seg, phase): + theta = seg / SEGMENTS * tau + radial = (cos(theta), 0.0, sin(theta)) + center = (R * radial[0], 0.0, R * radial[2]) + twist = theta * 0.5 + phase + w = ( + WIDTH * cos(twist) * radial[0], + WIDTH * sin(twist), + WIDTH * cos(twist) * radial[2], + ) + left = [center[0] - w[0], center[1] - w[1], center[2] - w[2]] + right = [center[0] + w[0], center[1] + w[1], center[2] + w[2]] + return left, right + + +def setup(): + global p + size(900, 700) + window_title(f"Particle surface — {N:,}-vertex Mobius strip") + mode_3d() + + p = create_particles(capacity=N, attributes=[Attribute.position()]) + + +def draw(): + background(6, 8, 14) + + phase = elapsed_time * 0.25 + positions = [] + for seg in range(SEGMENTS): + left, right = band(seg, phase) + positions.append(left) + positions.append(right) + p.buffer("position").write(positions) + + t = elapsed_time * 0.15 + r = R * 3.0 + camera_position(cos(t) * r, R * 1.2, sin(t) * r) + camera_look_at(0.0, 0.0, 0.0) + + particles(p, topology="triangle_strip") + + +run() diff --git a/crates/processing_pyo3/examples/style_stack.py b/crates/processing_pyo3/examples/style_stack.py new file mode 100644 index 00000000..0023faea --- /dev/null +++ b/crates/processing_pyo3/examples/style_stack.py @@ -0,0 +1,43 @@ +# Style Stack +# +# A grid of spinners. Each cell sets up its own transform and color inside +# push()/pop(), so nothing leaks to its neighbours. +from mewnala import * +from math import sin + +GRID = 6 +t = 0.0 + + +def setup(): + size(600, 600) + + +def draw(): + global t + background(16, 16, 20) + + cell = width / GRID + for gy in range(GRID): + for gx in range(GRID): + spin = t + (gx + gy) * 0.4 + pulse = 0.5 + 0.5 * sin(spin) + + push() + translate((gx + 0.5) * cell, (gy + 0.5) * cell) + rotate(spin) + + no_stroke() + fill(235, 90 + 130 * pulse, 60) + rect_mode(CENTER) + rect(0, 0, cell * (0.3 + 0.15 * pulse), cell * 0.3) + + no_fill() + stroke(255, 255, 255, 40) + circle(0, 0, cell * 0.72) + pop() + + t += 0.02 + + +run() diff --git a/crates/processing_pyo3/examples/text.py b/crates/processing_pyo3/examples/text.py new file mode 100644 index 00000000..fcad5e91 --- /dev/null +++ b/crates/processing_pyo3/examples/text.py @@ -0,0 +1,33 @@ +"""Drawing type through the global text API (text, text_size, text_align, ...).""" +from mewnala import * +from math import sin + + +def setup(): + size(640, 360) + + +def draw(): + background(16, 16, 24) + + # Centered title. + fill(255) + text_align(CENTER, CENTER) + text_size(56) + text("hello, processing", width / 2, height / 2) + + # Pulsing subtitle. + pulse = 150 + 105 * sin(frame_count * 0.05) + fill(120, pulse, 255) + text_size(20) + text("global text now works", width / 2, height / 2 + 60) + + # Bottom-left frame counter, left/baseline aligned. + fill(180) + text_align(LEFT, BASELINE) + text_size(14) + text(f"frame {frame_count}", 16, height - 16) + + +# TODO: this should happen implicitly on module load somehow +run() diff --git a/crates/processing_pyo3/mewnala/__init__.py b/crates/processing_pyo3/mewnala/__init__.py index 13b1b81a..f3d6f78a 100644 --- a/crates/processing_pyo3/mewnala/__init__.py +++ b/crates/processing_pyo3/mewnala/__init__.py @@ -1,17 +1,30 @@ from .mewnala import * +# `from .mewnala import *` above binds Processing globals that shadow Python +# builtins (e.g. `set`, `filter`, `get`), so this module reaches builtins it needs +# through an explicit alias rather than the shadowed names. +import builtins as _builtins + # re-export the native submodules as submodules of this module, if they exist -# this allows users to import from `mewnala.math` and `mewnala.color` -# if they exist, without needing to know about the internal structure of the native module +# this allows users to import from `mewnala.math` without needing to know about +# the internal structure of the native module import sys as _sys from . import mewnala as _native -for _name in ("math", "color"): + +for _name in ("math",): _sub = getattr(_native, _name, None) if _sub is not None: _sys.modules[f"{__name__}.{_name}"] = _sub +_color = getattr(_native, "color", None) +if _color is not None: + _sys.modules[f"{__name__}.color"] = _color + +from . import math # noqa: E402 (Python submodule, extends native math) +from .math import * # noqa: E402,F401,F403 + # global var handling. for wildcard import of our module, we copy into globals, otherwise -# we dispatch to get attr and call the underlying getter method +# we dispatch to get attr and call the underlying getter method _DYNAMIC_GRAPHICS_ATTRS = ( "width", @@ -104,11 +117,10 @@ def __getattr__(name): def __dir__(): - return sorted(set(list(globals().keys()) + list(_DYNAMIC))) - + return sorted(_builtins.set(list(globals().keys()) + list(_DYNAMIC))) __all__ = sorted( - {n for n in dir(_native) if not n.startswith("_")} | set(_DYNAMIC) + {n for n in dir(_native) if not n.startswith("_")} | _builtins.set(_DYNAMIC) ) del _sys, _name, _sub diff --git a/crates/processing_pyo3/mewnala/math.py b/crates/processing_pyo3/mewnala/math.py new file mode 100644 index 00000000..c399e292 --- /dev/null +++ b/crates/processing_pyo3/mewnala/math.py @@ -0,0 +1,127 @@ +"""Processing math methods and vector/quaternion types.""" +import math as _math +from .mewnala import math as _native_math +from math import ( + sin, cos, tan, + atan, atan2, + ceil, floor, + degrees, radians, +) + +Vec2 = _native_math.Vec2 +Vec3 = _native_math.Vec3 +Vec4 = _native_math.Vec4 +Quat = _native_math.Quat +VecIter = _native_math.PyVecIter +vec2 = _native_math.vec2 +vec3 = _native_math.vec3 +vec4 = _native_math.vec4 +quat = _native_math.quat + +_NAN = float("nan") +_INF = float("inf") + + +def _safe_div(num, den): + if den == 0: + if num == 0: + return _NAN + return _INF if num > 0 else -_INF + return num / den + + +def sq(x): + return x * x + + +def pow(base, exponent): + if base < 0 and not float(exponent).is_integer(): + return _NAN + try: + return base ** exponent + except (OverflowError, ZeroDivisionError): + return _INF + + +def sqrt(x): + if x < 0: + return _NAN + return _math.sqrt(x) + + +def exp(x): + try: + return _math.exp(x) + except OverflowError: + return _INF + + +def log(x): + if x == 0: + return -_INF + if x < 0: + return _NAN + return _math.log(x) + + +def asin(x): + if x < -1 or x > 1: + return _NAN + return _math.asin(x) + + +def acos(x): + if x < -1 or x > 1: + return _NAN + return _math.acos(x) + + +def round(x): + return _math.floor(x + 0.5) + + +def constrain(value, low, high): + if value < low: + return low + if value > high: + return high + return value + + +def lerp(start, stop, amt): + return start + (stop - start) * amt + + +def norm(value, start, stop): + return _safe_div(value - start, stop - start) + + +def remap(value, start1, stop1, start2, stop2, within_bounds=False): + mapped = start2 + (stop2 - start2) * _safe_div(value - start1, stop1 - start1) + if not within_bounds: + return mapped + if start2 < stop2: + return constrain(mapped, start2, stop2) + return constrain(mapped, stop2, start2) + + +def mag(*args): + if len(args) == 2: + a, b = args + return sqrt(a * a + b * b) + if len(args) == 3: + a, b, c = args + return sqrt(a * a + b * b + c * c) + raise TypeError(f"mag() takes 2 or 3 arguments ({len(args)} given)") + + +def dist(*args): + if len(args) == 4: + x1, y1, x2, y2 = args + dx, dy = x2 - x1, y2 - y1 + return sqrt(dx * dx + dy * dy) + if len(args) == 6: + x1, y1, z1, x2, y2, z2 = args + dx, dy, dz = x2 - x1, y2 - y1, z2 - z1 + return sqrt(dx * dx + dy * dy + dz * dz) + raise TypeError(f"dist() takes 4 or 6 arguments ({len(args)} given)") diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs new file mode 100644 index 00000000..e1f392b3 --- /dev/null +++ b/crates/processing_pyo3/src/compute.rs @@ -0,0 +1,345 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{ + exceptions::{PyIndexError, PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::{PyBytes, PyList, PySlice, PySliceIndices}, +}; + +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; + +#[pyclass(unsendable)] +pub struct Buffer { + pub(crate) entity: Entity, + element_type: Option, + size: u64, + borrowed: bool, +} + +impl Buffer { + pub(crate) fn from_entity(entity: Entity, element_type: Option) -> Self { + let size = buffer_size(entity).unwrap_or(0); + Self { + entity, + element_type, + size, + borrowed: true, + } + } + + pub(crate) fn components(&self) -> Option { + self.element_type + .as_ref() + .and_then(|et| et.byte_size()) + .map(|s| (s / 4) as u32) + } +} + +impl Buffer { + pub(crate) fn create(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + let (entity, size, element_type) = if let Some(data) = data { + let (bytes, element_type) = shader_values_to_bytes(data)?; + let size = bytes.len() as u64; + let entity = buffer_create_with_data(bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, element_type) + } else { + let size = size.unwrap_or(0); + let entity = + buffer_create(size).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, None) + }; + Ok(Self { + entity, + element_type, + size, + borrowed: false, + }) + } +} + +#[pymethods] +impl Buffer { + pub fn __len__(&self) -> usize { + match &self.element_type { + Some(et) => et + .byte_size() + .map(|s| self.size as usize / s) + .unwrap_or(self.size as usize), + None => self.size as usize, + } + } + + pub fn __getitem__(&self, py: Python<'_>, index: &Bound<'_, PyAny>) -> PyResult> { + let Some(ref et) = self.element_type else { + return Err(PyTypeError::new_err("no element type; write values first")); + }; + let elem_size = et.byte_size().unwrap() as u64; + + let read = |i: isize| -> PyResult> { + let bytes = buffer_read_element(self.entity, i as u64 * elem_size, elem_size) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let sv = et + .read_from_bytes(&bytes) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode element"))?; + shader_value_to_py(py, &sv) + }; + + if let Ok(i) = index.extract::() { + Ok(read(self.normalize_index(i)? as isize)?.into()) + } else if let Ok(slice) = index.cast::() { + let indices = slice.indices(self.__len__() as isize)?; + let values = slice_positions(&indices) + .map(read) + .collect::>>()?; + Ok(PyList::new(py, values)?.into()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn __setitem__( + &mut self, + index: &Bound<'_, PyAny>, + value: &Bound<'_, PyAny>, + ) -> PyResult<()> { + if let Ok(i) = index.extract::() { + let sv = py_to_shader_value(value)?; + self.check_element_type(&sv)?; + let bytes = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = bytes.len() as u64; + let i = self.normalize_index(i)?; + buffer_write_element(self.entity, i as u64 * elem_size, bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } else if let Ok(slice) = index.cast::() { + let (src_bytes, element_type) = shader_values_to_bytes(value)?; + let et = element_type + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = et.byte_size().unwrap() as u64; + self.check_element_type(&et)?; + let indices = slice.indices(self.__len__() as isize)?; + let src_elems = src_bytes.len() as u64 / elem_size; + if indices.slicelength as u64 != src_elems { + return Err(PyValueError::new_err(format!( + "slice length {} does not match value length {}", + indices.slicelength, src_elems + ))); + } + for (pos, chunk) in + slice_positions(&indices).zip(src_bytes.chunks_exact(elem_size as usize)) + { + buffer_write_element(self.entity, pos as u64 * elem_size, chunk.to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn write(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + // bytes path skips per-element conversion for large uploads + if let Ok(b) = values.cast::() { + return buffer_write(self.entity, b.as_bytes().to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + let (bytes, element_type) = shader_values_to_bytes(values)?; + self.element_type = element_type; + buffer_write(self.entity, bytes).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn read<'py>(&mut self, py: Python<'py>) -> PyResult> { + let data = buffer_read(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + let Some(ref template) = self.element_type else { + return Ok(PyBytes::new(py, &data).into_any()); + }; + + let elem_size = template + .byte_size() + .ok_or_else(|| PyRuntimeError::new_err("unsupported element type"))?; + + let values = data + .chunks_exact(elem_size) + .map(|chunk| { + let sv = template + .read_from_bytes(chunk) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode bytes"))?; + shader_value_to_py(py, &sv) + }) + .collect::>>()?; + + Ok(PyList::new(py, values)?.into_any()) + } + + #[pyo3(signature = (op = "sum"))] + pub fn reduce(&self, op: &str) -> PyResult { + use processing::prelude::constants as c; + use processing_render::particles::reduce::{ + REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce as reduce_buffer, + }; + let mode = if op.eq_ignore_ascii_case(c::SUM) { + REDUCE_OP_SUM + } else if op.eq_ignore_ascii_case(c::MIN) { + REDUCE_OP_MIN + } else if op.eq_ignore_ascii_case(c::MAX) { + REDUCE_OP_MAX + } else { + return Err(PyValueError::new_err(format!("reduce: unknown op {op:?}"))); + }; + reduce_buffer(self.entity, mode).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Buffer { + fn check_element_type(&mut self, sv: &ShaderValue) -> PyResult<()> { + match &self.element_type { + Some(existing) if std::mem::discriminant(existing) != std::mem::discriminant(sv) => { + Err(PyTypeError::new_err(format!( + "buffer element type mismatch: expected {existing:?}, got {sv:?}" + ))) + } + Some(_) => Ok(()), + None => { + self.element_type = Some(sv.clone()); + Ok(()) + } + } + } + + fn normalize_index(&self, i: isize) -> PyResult { + let len = self.__len__() as isize; + let i = if i < 0 { len + i } else { i }; + if i < 0 || i >= len { + Err(PyIndexError::new_err("buffer index out of range")) + } else { + Ok(i as usize) + } + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + if !self.borrowed { + let _ = buffer_destroy(self.entity); + } + } +} + +fn slice_positions(indices: &PySliceIndices) -> impl Iterator + use<> { + let PySliceIndices { + start, + step, + slicelength, + .. + } = *indices; + (0..slicelength as isize).map(move |i| start + i * step) +} + +fn shader_values_to_bytes(values: &Bound<'_, PyAny>) -> PyResult<(Vec, Option)> { + let mut bytes = Vec::new(); + let mut element_type: Option = None; + for item in values.try_iter()? { + let sv = py_to_shader_value(&item?)?; + if let Some(ref existing) = element_type + && std::mem::discriminant(existing) != std::mem::discriminant(&sv) + { + return Err(PyTypeError::new_err(format!( + "buffer elements must all share the same type: expected {existing:?}, got {sv:?}" + ))); + } + let b = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + element_type.get_or_insert(sv); + bytes.extend_from_slice(&b); + } + Ok((bytes, element_type)) +} + +fn shader_value_to_py<'py>(py: Python<'py>, sv: &ShaderValue) -> PyResult> { + fn list<'py, T: pyo3::IntoPyObject<'py> + Copy>( + py: Python<'py>, + xs: &[T], + ) -> PyResult> { + Ok(PyList::new(py, xs.iter().copied())?.into_any()) + } + match sv { + ShaderValue::Float(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Int(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::UInt(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Float2(v) => list(py, v), + ShaderValue::Float3(v) => list(py, v), + ShaderValue::Float4(v) => list(py, v), + ShaderValue::Int2(v) => list(py, v), + ShaderValue::Int3(v) => list(py, v), + ShaderValue::Int4(v) => list(py, v), + ShaderValue::UInt2(v) => list(py, v), + ShaderValue::UInt3(v) => list(py, v), + ShaderValue::UInt4(v) => list(py, v), + ShaderValue::Mat4(v) => list(py, v), + ShaderValue::Texture(_) + | ShaderValue::Buffer(_) + | ShaderValue::MeshAttribute(..) + | ShaderValue::MeshIndex(_) => Err(PyRuntimeError::new_err( + "cannot convert Texture/Buffer/Mesh* to Python value", + )), + } +} + +#[pyclass(unsendable)] +pub struct Compute { + pub(crate) entity: Entity, +} + +impl Compute { + pub(crate) fn from_entity(entity: Entity) -> Self { + Self { entity } + } +} + +impl Compute { + pub(crate) fn create(shader: &Shader) -> PyResult { + let entity = + compute_create(shader.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } +} + +pub(crate) fn set_compute_kwargs( + entity: Entity, + kwargs: &Bound<'_, pyo3::types::PyDict>, +) -> PyResult<()> { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let value = py_to_shader_value(&value)?; + compute_set(entity, &name, value).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) +} + +#[pymethods] +impl Compute { + #[pyo3(signature = (**kwargs))] + pub fn set(&self, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>) -> PyResult<()> { + match kwargs { + Some(kwargs) => set_compute_kwargs(self.entity, kwargs), + None => Ok(()), + } + } + + pub fn dispatch(&self, x: u32, y: u32, z: u32) -> PyResult<()> { + compute_dispatch(self.entity, x, y, z).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Compute { + fn drop(&mut self) { + let _ = compute_destroy(self.entity); + } +} diff --git a/crates/processing_pyo3/src/constants.rs b/crates/processing_pyo3/src/constants.rs new file mode 100644 index 00000000..10b44594 --- /dev/null +++ b/crates/processing_pyo3/src/constants.rs @@ -0,0 +1,138 @@ +use processing::prelude::BlendMode; +use processing::prelude::constants as c; +use pyo3::prelude::*; +use pyo3::types::PyModule; + +use crate::PyBlendMode; + +macro_rules! add { + ($m:expr, $($name:ident),+ $(,)?) => { + $( $m.add(stringify!($name), c::$name)?; )+ + }; +} + +pub fn register(m: &Bound<'_, PyModule>) -> PyResult<()> { + add!(m, ROUND, SQUARE, PROJECT, MITER, BEVEL); + add!( + m, + POLYGON, + POINTS, + LINES, + TRIANGLES, + TRIANGLE_FAN, + TRIANGLE_STRIP, + QUADS, + QUAD_STRIP, + LINE_STRIP + ); + add!(m, CORNER, CORNERS, CENTER, RADIUS); + add!(m, OPEN, CHORD, PIE, CLOSE); + + m.add("INVERT", crate::filter::INVERT_U8)?; + m.add("GRAY", crate::filter::GRAY_U8)?; + m.add("THRESHOLD", crate::filter::THRESHOLD_U8)?; + m.add("POSTERIZE", crate::filter::POSTERIZE_U8)?; + m.add("BLUR", crate::filter::BLUR_U8)?; + m.add("OPAQUE", crate::filter::OPAQUE_U8)?; + m.add("ERODE", crate::filter::ERODE_U8)?; + m.add("DILATE", crate::filter::DILATE_U8)?; + + add!(m, LEFT, RIGHT); + // text align (LEFT/RIGHT/CENTER above), style, and wrap constants + add!(m, TOP, BOTTOM, BASELINE); + add!(m, NORMAL, ITALIC, BOLD, BOLD_ITALIC); + add!(m, WORD, CHAR); + add!(m, NEAREST, CLAMP, REPEAT, MIRROR); + add!(m, SRGB, LINEAR, HSL, HSV, HWB, OKLAB, OKLCH, LAB, LCH, XYZ); + add!( + m, PI, TWO_PI, HALF_PI, QUARTER_PI, TAU, DEG_TO_RAD, RAD_TO_DEG + ); + + add!( + m, MAP, COMBINE, MIX, LOOKUP, REDUCE, EXTRACT, PACK, GENERATE + ); + add!(m, AFFINE, ABS, NEGATE, FLOOR, SQRT); + add!(m, GREATER, LESS, GEQ, LEQ, EQ, NEQ); + add!(m, SUB, MUL, DIV, POW); + add!(m, LENGTH, SUM, SUMSQ, MEAN, MIN, MAX); + add!(m, UNIFORM, SIGNED, GAUSSIAN); + add!(m, NEIGHBOR); + add!(m, COUNT, DENSITY); + add!(m, CONSTANT, SMOOTHSTEP, QUADRATIC, CUBIC, INVERSE); + add!( + m, + NOISE, + TRANSFORM, + ATTRACT, + DRAG, + VORTEX, + FORCE, + INTEGRATE, + AGE, + IMPULSE, + ORIENT, + FIELD, + BOUNDS_SPHERE, + BOUNDS_BOX + ); + + add!( + m, KEY_A, KEY_B, KEY_C, KEY_D, KEY_E, KEY_F, KEY_G, KEY_H, KEY_I, KEY_J, KEY_K, KEY_L, + KEY_M, KEY_N, KEY_O, KEY_P, KEY_Q, KEY_R, KEY_S, KEY_T, KEY_U, KEY_V, KEY_W, KEY_X, KEY_Y, + KEY_Z + ); + add!( + m, KEY_0, KEY_1, KEY_2, KEY_3, KEY_4, KEY_5, KEY_6, KEY_7, KEY_8, KEY_9 + ); + add!( + m, + SPACE, + QUOTE, + COMMA, + MINUS, + PERIOD, + SLASH, + SEMICOLON, + EQUAL, + BRACKET_LEFT, + BACKSLASH, + BRACKET_RIGHT, + BACKQUOTE + ); + add!( + m, + ESCAPE, + ENTER, + TAB, + BACKSPACE, + INSERT, + DELETE, + UP, + DOWN, + LEFT_ARROW, + RIGHT_ARROW, + PAGE_UP, + PAGE_DOWN, + HOME, + END + ); + add!(m, SHIFT, CONTROL, ALT, SUPER); + add!(m, F1, F2, F3, F4, F5, F6, F7, F8, F9, F10, F11, F12); + + // Objects rather than strings, passed straight to blend_mode(). + m.add("BLEND", PyBlendMode::from_preset(BlendMode::Blend))?; + m.add("ADD", PyBlendMode::from_preset(BlendMode::Add))?; + m.add("SUBTRACT", PyBlendMode::from_preset(BlendMode::Subtract))?; + m.add("DARKEST", PyBlendMode::from_preset(BlendMode::Darkest))?; + m.add("LIGHTEST", PyBlendMode::from_preset(BlendMode::Lightest))?; + m.add( + "DIFFERENCE", + PyBlendMode::from_preset(BlendMode::Difference), + )?; + m.add("EXCLUSION", PyBlendMode::from_preset(BlendMode::Exclusion))?; + m.add("MULTIPLY", PyBlendMode::from_preset(BlendMode::Multiply))?; + m.add("SCREEN", PyBlendMode::from_preset(BlendMode::Screen))?; + m.add("REPLACE", PyBlendMode::from_preset(BlendMode::Replace))?; + + Ok(()) +} diff --git a/crates/processing_pyo3/src/filter.rs b/crates/processing_pyo3/src/filter.rs new file mode 100644 index 00000000..c59aeaef --- /dev/null +++ b/crates/processing_pyo3/src/filter.rs @@ -0,0 +1,184 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::{PyDict, PyTuple}, +}; +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; + +pub const INVERT_U8: u8 = 0; +pub const GRAY_U8: u8 = 1; +pub const THRESHOLD_U8: u8 = 2; +pub const POSTERIZE_U8: u8 = 3; +pub const BLUR_U8: u8 = 4; +pub const OPAQUE_U8: u8 = 5; +pub const ERODE_U8: u8 = 6; +pub const DILATE_U8: u8 = 7; + +fn resolve_user_filter( + shader_entity: Entity, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, +) -> PyResult { + if !args.is_empty() { + return Err(PyValueError::new_err( + "filter(shader): parameters must be passed as keyword arguments", + )); + } + let entity = + filter_create(shader_entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + if name == "passes" { + let passes: u32 = value.extract()?; + filter_set_passes(entity, passes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + continue; + } + let sv = py_to_shader_value(&value)?; + filter_set(entity, &name, sv).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + } + Ok(entity) +} + +fn create_builtin(kind: u8) -> PyResult { + let result = match kind { + INVERT_U8 => filter_invert(), + GRAY_U8 => filter_gray(), + THRESHOLD_U8 => filter_threshold(), + POSTERIZE_U8 => filter_posterize(), + BLUR_U8 => filter_blur(), + OPAQUE_U8 => filter_opaque(), + ERODE_U8 => filter_erode(), + DILATE_U8 => filter_dilate(), + n => { + return Err(PyValueError::new_err(format!( + "filter(): unknown or unimplemented filter constant {n}" + ))); + } + }; + result.map_err(|e| PyRuntimeError::new_err(format!("{e}"))) +} + +pub fn resolve_filter( + kind: &Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, +) -> PyResult { + if let Ok(shader) = kind.extract::>() { + return resolve_user_filter(shader.entity, args, kwargs); + } + + let Ok(kind_u8) = kind.extract::() else { + return Err(PyTypeError::new_err( + "filter(): first argument must be a filter constant (INVERT, GRAY, ...) or a Shader", + )); + }; + let entity = create_builtin(kind_u8)?; + let set = |name: &str, value: ShaderValue| { + filter_set(entity, name, value).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + }; + + match kind_u8 { + INVERT_U8 => reject_params(args, kwargs, "INVERT")?, + GRAY_U8 => reject_params(args, kwargs, "GRAY")?, + OPAQUE_U8 => reject_params(args, kwargs, "OPAQUE")?, + ERODE_U8 => reject_params(args, kwargs, "ERODE")?, + DILATE_U8 => reject_params(args, kwargs, "DILATE")?, + THRESHOLD_U8 => { + let cutoff = parse_scalar(args, kwargs, "THRESHOLD", "cutoff", Some(0.5))?; + set("cutoff", ShaderValue::Float(cutoff))?; + } + POSTERIZE_U8 => { + let levels_f = parse_scalar(args, kwargs, "POSTERIZE", "levels", None)?; + if !(2.0..=255.0).contains(&levels_f) || levels_f.fract() != 0.0 { + return Err(PyValueError::new_err( + "filter(POSTERIZE, levels): levels must be an integer in 2..=255", + )); + } + set("levels", ShaderValue::UInt(levels_f as u32))?; + } + BLUR_U8 => { + let radius = parse_scalar(args, kwargs, "BLUR", "radius", Some(1.0))?; + set("radius", ShaderValue::Float(radius))?; + } + _ => {} + } + + Ok(entity) +} + +fn reject_params( + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + name: &str, +) -> PyResult<()> { + if args.len() > 0 { + return Err(PyValueError::new_err(format!( + "filter({name}): takes no parameters, got {} positional", + args.len() + ))); + } + if let Some(kw) = kwargs + && !kw.is_empty() + { + return Err(PyValueError::new_err(format!( + "filter({name}): takes no parameters" + ))); + } + Ok(()) +} + +fn parse_scalar( + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + filter_name: &str, + kw_name: &str, + default: Option, +) -> PyResult { + if args.len() > 1 { + return Err(PyValueError::new_err(format!( + "filter({filter_name}): expected at most 1 positional arg, got {}", + args.len() + ))); + } + if let Some(kw) = kwargs { + for key in kw.keys().iter() { + let k_str = key.str().map(|s| s.to_string()).unwrap_or_default(); + if k_str != kw_name { + return Err(PyValueError::new_err(format!( + "filter({filter_name}): unknown keyword arg `{k_str}` (expected `{kw_name}`)" + ))); + } + } + } + + let from_kw = kwargs + .and_then(|kw| kw.get_item(kw_name).ok().flatten()) + .map(|v| v.extract::()) + .transpose()?; + + let from_pos = if args.len() > 0 { + Some(args.get_item(0)?.extract::()?) + } else { + None + }; + + match (from_pos, from_kw) { + (Some(_), Some(_)) => Err(PyValueError::new_err(format!( + "filter({filter_name}): got both positional and keyword `{kw_name}`" + ))), + (Some(v), None) | (None, Some(v)) => Ok(v), + (None, None) => default.ok_or_else(|| { + PyValueError::new_err(format!( + "filter({filter_name}): missing required parameter `{kw_name}`" + )) + }), + } +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index cafd305d..e801fad8 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -3,21 +3,203 @@ use crate::glfw::GlfwContext; use crate::input; use crate::math::{extract_vec2, extract_vec3, extract_vec4}; use bevy::{ - color::{ColorToPacked, Srgba}, - math::Vec4, + color::{ColorToPacked, LinearRgba, Srgba}, + math::{Affine3A, Mat4, Vec3, Vec4}, prelude::Entity, render::render_resource::{Extent3d, TextureFormat}, }; use processing::prelude::*; use pyo3::{ - exceptions::PyRuntimeError, + exceptions::{PyRuntimeError, PyValueError}, prelude::*, - types::{PyDict, PyTuple}, + types::{PyDict, PyList, PyTuple}, }; +use std::sync::Mutex; #[cfg(feature = "cuda")] use crate::cuda::CudaImage; +/// Flatten `*args` of numbers (or a single list/tuple of numbers) into a `Vec`. +fn flatten_floats(args: &Bound<'_, PyTuple>) -> PyResult> { + if args.len() == 1 { + if let Ok(seq) = args.get_item(0)?.extract::>() { + return Ok(seq); + } + } + let mut out = Vec::with_capacity(args.len()); + for item in args.iter() { + out.push(item.extract::()?); + } + Ok(out) +} + +/// Build a Python list of `Color` objects from linear-RGBA pixels. +fn pixels_to_pylist(py: Python<'_>, pixels: &[LinearRgba]) -> PyResult> { + let colors = pixels + .iter() + .map(|p| crate::color::PyColor(bevy::prelude::Color::LinearRgba(*p))); + Ok(PyList::new(py, colors)?.unbind()) +} + +/// Convert a Python sequence of colors (as returned by `load_pixels()`, or +/// individual `Color`/`[r,g,b,a]` values) into linear-RGBA pixels. +fn pyseq_to_pixels(seq: &Bound<'_, PyAny>) -> PyResult> { + let mut out = Vec::with_capacity(seq.len().unwrap_or(0)); + for item in seq.try_iter()? { + let item = item?; + let color = if let Ok(c) = item.extract::() { + c.0 + } else if let Ok(rgba) = item.extract::<[f32; 4]>() { + bevy::prelude::Color::LinearRgba(LinearRgba::new(rgba[0], rgba[1], rgba[2], rgba[3])) + } else if let Ok(rgb) = item.extract::<[f32; 3]>() { + bevy::prelude::Color::LinearRgba(LinearRgba::new(rgb[0], rgb[1], rgb[2], 1.0)) + } else { + return Err(PyValueError::new_err( + "pixels must contain Color values or [r, g, b, (a)] sequences", + )); + }; + out.push(LinearRgba::from(color)); + } + Ok(out) +} + +/// Resolve the sequence `update_pixels()` should write: an explicit argument if +/// given, otherwise the buffer cached by `load_pixels()`. +fn resolve_pixels_arg<'py>( + py: Python<'py>, + pixels: Option<&Bound<'py, PyAny>>, + cache: &Mutex>>, +) -> PyResult> { + match pixels { + Some(p) => Ok(p.clone()), + None => { + let cached = cache.lock().unwrap().as_ref().map(|l| l.clone_ref(py)); + match cached { + Some(list) => Ok(list.into_bound(py).into_any()), + None => Err(PyRuntimeError::new_err( + "update_pixels() called before load_pixels()", + )), + } + } + } +} + +fn rt_err(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) +} + +/// Composite mode code for `mask()` (see `filters/composite.wgsl`). +const COMPOSITE_MODE_MASK: u32 = 10; +/// Composite mode code for `copy()` — REPLACE (matches `BlendMode::Replace`). +const COMPOSITE_MODE_REPLACE: u32 = 9; + +/// Normalize a pixel rect `(x, y, w, h)` to uv-space `(x0, y0, x1, y1)`; `None` +/// means the full `(0, 0, 1, 1)` extent. +fn normalize_rect(rect: Option<[f32; 4]>, w: f32, h: f32) -> [f32; 4] { + match rect { + None => [0.0, 0.0, 1.0, 1.0], + Some([x, y, rw, rh]) => [x / w, y / h, (x + rw) / w, (y + rh) / h], + } +} + +/// Normalize a source pixel rect against the source image's own dimensions. +fn normalize_src_rect(entity: Entity, rect: Option<[f32; 4]>) -> PyResult<[f32; 4]> { + match rect { + None => Ok([0.0, 0.0, 1.0, 1.0]), + Some(r) => { + let (w, h) = image_size(entity).map_err(rt_err)?; + Ok(normalize_rect(Some(r), w as f32, h as f32)) + } + } +} + +/// Resolve a composite source (`Image`, `Webcam`, or `Graphics`) to a +/// texture-bearing entity, plus the graphics entity that must be flushed first +/// when the source is a graphics (so its latest content is what gets sampled). +/// A `Graphics` source uses its render-target surface entity, which carries an +/// `Image` component for offscreen graphics. +fn resolve_composite_source(src: &Bound<'_, PyAny>) -> PyResult<(Entity, Option)> { + if let Ok(img) = src.extract::() { + return Ok((img.entity, None)); + } + if let Ok(g) = src.extract::>() { + return Ok((g.surface.entity, Some(g.entity))); + } + Err(PyValueError::new_err( + "composite source must be an Image, Webcam, or Graphics", + )) +} + +/// Run the built-in composite filter: blend the `src` texture into the `dst` +/// graphics target using `mode`, with normalized src/dst rects. This is the +/// shader-based composite pass shared by `copy`/`blend`/`mask`. +fn composite_apply( + dst: Entity, + src: Entity, + mode: u32, + src_rect: [f32; 4], + dst_rect: [f32; 4], + opacity: f32, +) -> PyResult<()> { + use shader_value::ShaderValue; + let filter = filter_composite().map_err(rt_err)?; + filter_set(filter, "src", ShaderValue::Texture(src)).map_err(rt_err)?; + filter_set(filter, "mode", ShaderValue::UInt(mode)).map_err(rt_err)?; + filter_set(filter, "opacity", ShaderValue::Float(opacity)).map_err(rt_err)?; + filter_set(filter, "src_rect", ShaderValue::Float4(src_rect)).map_err(rt_err)?; + filter_set(filter, "dst_rect", ShaderValue::Float4(dst_rect)).map_err(rt_err)?; + graphics_apply_filter(dst, filter).map_err(rt_err) +} + +/// Write raw sRGB RGBA bytes to a PNG file on disk. +fn write_png_file(path: &str, width: u32, height: u32, rgba: &[u8]) -> PyResult<()> { + let lower = path.to_lowercase(); + if !lower.ends_with(".png") { + return Err(PyValueError::new_err(format!( + "save() currently supports only .png files, got {path:?}" + ))); + } + let file = std::fs::File::create(path) + .map_err(|e| PyRuntimeError::new_err(format!("create {path}: {e}")))?; + let writer = std::io::BufWriter::new(file); + let mut encoder = png::Encoder::new(writer, width, height); + encoder.set_color(png::ColorType::Rgba); + encoder.set_depth(png::BitDepth::Eight); + encoder.set_source_srgb(png::SrgbRenderingIntent::Perceptual); + let mut writer = encoder + .write_header() + .map_err(|e| PyRuntimeError::new_err(format!("PNG header: {e}")))?; + writer + .write_image_data(rgba) + .map_err(|e| PyRuntimeError::new_err(format!("PNG write: {e}"))) +} + +/// Parse Processing-style `applyMatrix`/`setMatrix` arguments into an `Affine3A`: +/// 6 values for a 2D affine (`n00 n01 n02 n10 n11 n12`) or 16 values for a full +/// 3D matrix in row-major order (matching Processing; glam is column-major). +fn affine_from_matrix_args(args: &Bound<'_, PyTuple>) -> PyResult { + let v = flatten_floats(args)?; + let mat = match v.len() { + 6 => Mat4::from_cols_array(&[ + v[0], v[3], 0.0, 0.0, // + v[1], v[4], 0.0, 0.0, // + 0.0, 0.0, 1.0, 0.0, // + v[2], v[5], 0.0, 1.0, + ]), + 16 => { + let mut arr = [0.0f32; 16]; + arr.copy_from_slice(&v); + Mat4::from_cols_array(&arr).transpose() + } + n => { + return Err(PyValueError::new_err(format!( + "matrix expects 6 (2D) or 16 (3D) values, got {n}" + ))); + } + }; + Ok(Affine3A::from_mat4(mat)) +} + #[cfg(feature = "cuda")] fn cuda_import_from_interface( entity: bevy::prelude::Entity, @@ -66,6 +248,12 @@ impl PyBlendMode { name: Some(mode.name()), } } + + /// The composite-shader mode code (the `BlendMode` discriminant) for this + /// blend mode, or `None` for custom modes with no named preset. + pub(crate) fn composite_mode(&self) -> Option { + self.name.and_then(BlendMode::from_name).map(|m| m as u32) + } } #[pymethods] @@ -132,6 +320,95 @@ impl PyBlendMode { const OP_MAX: u8 = 4; } +/// Configures how an image is sampled when drawn. +/// +/// Controls texture filtering and edge wrapping behavior. +/// +/// - `filter` — `LINEAR` (smooth, default) or `NEAREST` (pixelated). +/// - `wrap` — `CLAMP` (default), `REPEAT`, or `MIRROR`. +/// Use `wrap_x`/`wrap_y` to set each axis independently. +#[pyclass(from_py_object)] +#[derive(Clone)] +pub struct Sampler { + pub(crate) filter: u8, + pub(crate) wrap_x: u8, + pub(crate) wrap_y: u8, +} + +impl Sampler { + fn parse_filter(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(constants::LINEAR) => Ok(0), + _ if s.eq_ignore_ascii_case(constants::NEAREST) => Ok(1), + _ => Err(PyValueError::new_err(format!( + "unknown filter: {s:?} (expected {:?} or {:?})", + constants::LINEAR, + constants::NEAREST + ))), + } + } + + fn parse_wrap(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(constants::CLAMP) => Ok(0), + _ if s.eq_ignore_ascii_case(constants::REPEAT) => Ok(1), + _ if s.eq_ignore_ascii_case(constants::MIRROR) => Ok(2), + _ => Err(PyValueError::new_err(format!( + "unknown wrap: {s:?} (expected {:?}, {:?}, or {:?})", + constants::CLAMP, + constants::REPEAT, + constants::MIRROR + ))), + } + } + + fn filter_name(filter: u8) -> &'static str { + match filter { + 0 => constants::LINEAR, + 1 => constants::NEAREST, + _ => "?", + } + } + + fn wrap_name(wrap: u8) -> &'static str { + match wrap { + 0 => constants::CLAMP, + 1 => constants::REPEAT, + 2 => constants::MIRROR, + _ => "?", + } + } +} + +#[pymethods] +impl Sampler { + #[new] + #[pyo3(signature = (*, filter=constants::LINEAR, wrap=constants::CLAMP, wrap_x=None, wrap_y=None))] + fn new(filter: &str, wrap: &str, wrap_x: Option<&str>, wrap_y: Option<&str>) -> PyResult { + let wrap_default = Self::parse_wrap(wrap)?; + Ok(Self { + filter: Self::parse_filter(filter)?, + wrap_x: wrap_x + .map(Self::parse_wrap) + .transpose()? + .unwrap_or(wrap_default), + wrap_y: wrap_y + .map(Self::parse_wrap) + .transpose()? + .unwrap_or(wrap_default), + }) + } + + fn __repr__(&self) -> String { + format!( + "Sampler(filter={:?}, wrap_x={:?}, wrap_y={:?})", + Self::filter_name(self.filter), + Self::wrap_name(self.wrap_x), + Self::wrap_name(self.wrap_y) + ) + } +} + pub use crate::surface::Surface; #[pyclass] @@ -162,16 +439,230 @@ impl Light { // } // } +#[pyclass] +#[derive(Debug)] +pub struct Font { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Font { + /// Query variable font axes. + /// + /// Returns a list of `(tag, min, max, default)` tuples. + pub fn variations(&self) -> PyResult> { + font_variations(self.entity) + .map(|axes| { + axes.into_iter() + .map(|a| (a.tag, a.min, a.max, a.default)) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Query font metadata. + /// + /// Returns a `(family, style, weight, width, is_variable)` tuple. + pub fn metadata(&self) -> PyResult<(String, String, f32, f32, bool)> { + font_metadata(self.entity) + .map(|m| (m.family, m.style, m.weight, m.width, m.is_variable)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +/// Convert glyph outline groups into per-group lists of Python command tuples. +fn path_commands_to_py( + py: Python<'_>, + groups: Vec>, +) -> Vec>> { + use processing_render::render::primitive::text::PathCommand; + + let to_py = |cmd: PathCommand| -> Py { + match cmd { + PathCommand::MoveTo(x, y) => ("M", x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::LineTo(x, y) => ("L", x, y).into_pyobject(py).unwrap().into_any().unbind(), + PathCommand::QuadTo { cx, cy, x, y } => ("Q", cx, cy, x, y) + .into_pyobject(py) + .unwrap() + .into_any() + .unbind(), + PathCommand::CubicTo { + cx1, + cy1, + cx2, + cy2, + x, + y, + } => ("C", cx1, cy1, cx2, cy2, x, y) + .into_pyobject(py) + .unwrap() + .into_any() + .unbind(), + PathCommand::Close => ("Z",).into_pyobject(py).unwrap().into_any().unbind(), + } + }; + + groups + .into_iter() + .map(|group| group.into_iter().map(to_py).collect()) + .collect() +} + #[pyclass] #[derive(Debug)] pub struct Image { pub(crate) entity: Entity, + /// Cached `pixels` list populated by `load_pixels()` and written back by + /// `update_pixels()`, mirroring Processing's `PImage.pixels[]`. + pixel_cache: Mutex>>, +} + +impl Image { + pub(crate) fn wrap(entity: Entity) -> Self { + Self { + entity, + pixel_cache: Mutex::new(None), + } + } +} + +pub(crate) struct ImageRef { + pub entity: Entity, +} + +impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { + type Error = PyErr; + + fn extract(ob: pyo3::Borrowed<'a, 'py, PyAny>) -> PyResult { + if let Ok(img) = ob.extract::>() { + return Ok(ImageRef { entity: img.entity }); + } + #[cfg(feature = "webcam")] + if let Ok(cam) = ob.extract::>() { + return Ok(ImageRef { + entity: cam.image_entity()?, + }); + } + Err(pyo3::exceptions::PyTypeError::new_err( + "expected an Image or Webcam", + )) + } } +#[pymethods] impl Image { - #[expect(dead_code)] // it's only used by webcam atm - pub(crate) fn from_entity(entity: Entity) -> Self { - Self { entity } + /// Applies a `Sampler` to this image, controlling filtering and wrapping. + /// + /// ```python + /// s = Sampler(filter=NEAREST, wrap=REPEAT) + /// img.sampler(s) + /// ``` + fn sampler(&self, sampler: &Sampler) -> PyResult<()> { + image_set_sampler(self.entity, sampler.filter, sampler.wrap_x, sampler.wrap_y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Image width in pixels. + #[getter] + fn width(&self) -> PyResult { + image_size(self.entity) + .map(|(w, _)| w) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Image height in pixels. + #[getter] + fn height(&self) -> PyResult { + image_size(self.entity) + .map(|(_, h)| h) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read a single pixel as a `Color` (Processing `get`). + fn get(&self, x: u32, y: u32) -> PyResult { + let (w, h) = + image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if x >= w || y >= h { + return Err(PyValueError::new_err(format!( + "pixel ({x}, {y}) out of bounds for {w}x{h} image" + ))); + } + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let p = pixels[(y * w + x) as usize]; + Ok(crate::color::PyColor(bevy::prelude::Color::LinearRgba(p))) + } + + /// Write a single pixel (Processing `set`). Accepts a `Color`, hex string, + /// or color components in the default (sRGB) color mode. + #[pyo3(signature = (x, y, *args))] + fn set(&self, x: u32, y: u32, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode(args, &ColorMode::default())?; + image_update_region(self.entity, x, y, 1, 1, &[LinearRgba::from(color)]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read all pixels into the `pixels` list (Processing `loadPixels`). + fn load_pixels(&self, py: Python<'_>) -> PyResult> { + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let list = pixels_to_pylist(py, &pixels)?; + *self.pixel_cache.lock().unwrap() = Some(list.clone_ref(py)); + Ok(list) + } + + /// The pixel buffer as a list of `Color` (Processing `pixels[]`). Auto-loads + /// on first access if `load_pixels()` has not been called. + #[getter] + fn pixels(&self, py: Python<'_>) -> PyResult> { + let cached = self + .pixel_cache + .lock() + .unwrap() + .as_ref() + .map(|list| list.clone_ref(py)); + match cached { + Some(list) => Ok(list), + None => self.load_pixels(py), + } + } + + /// Write the `pixels` list back to the image (Processing `updatePixels`). If + /// `pixels` is omitted, the cached buffer from `load_pixels()` is used. + #[pyo3(signature = (pixels=None))] + fn update_pixels(&self, py: Python<'_>, pixels: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let seq = resolve_pixels_arg(py, pixels, &self.pixel_cache)?; + let data = pyseq_to_pixels(&seq)?; + image_update(self.entity, &data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Copies a source into this image via a GPU blit (a sampling copy that + /// handles differing size and format). `src` may be another `Image` or a + /// `Graphics` render target — e.g. render into an offscreen graphics, then + /// `img.copy_from(g)` to pull the result into a plain, sampleable image. + fn copy_from(&self, src: &Bound<'_, PyAny>) -> PyResult<()> { + if let Ok(img) = src.extract::>() { + image_copy_from(self.entity, img.entity, false).map_err(rt_err) + } else if let Ok(g) = src.extract::>() { + image_copy_from(self.entity, g.entity, true).map_err(rt_err) + } else { + Err(PyValueError::new_err( + "copy_from() expects an Image or Graphics source", + )) + } + } + + /// Save the image to a PNG file (Processing `save`). + fn save(&self, filename: &str) -> PyResult<()> { + let (w, h) = + image_size(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let pixels = + image_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let rgba: Vec = pixels + .iter() + .flat_map(|p| Srgba::from(*p).to_u8_array()) + .collect(); + write_png_file(filename, w, h, &rgba) } } @@ -200,48 +691,30 @@ pub struct Geometry { pub(crate) entity: Entity, } -#[pyclass] -pub enum Topology { - PointList = 0, - LineList = 1, - LineStrip = 2, - TriangleList = 3, - TriangleStrip = 4, -} - -impl Topology { - pub fn as_u8(&self) -> u8 { - match self { - Self::PointList => 0, - Self::LineList => 1, - Self::LineStrip => 2, - Self::TriangleList => 3, - Self::TriangleStrip => 4, - } - } -} - #[pyclass] pub struct Sketch { pub source: String, } -#[pymethods] impl Geometry { - #[new] - #[pyo3(signature = (**kwargs))] - pub fn new(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { - let topology = kwargs - .and_then(|k| k.get_item("topology").ok().flatten()) - .and_then(|t| t.cast_into::().ok()) - .and_then(|t| geometry::Topology::from_u8(t.borrow().as_u8())) - .unwrap_or(geometry::Topology::TriangleList); + pub(crate) fn create(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let topology = match kwargs.and_then(|k| k.get_item("topology").ok().flatten()) { + Some(t) => { + let s = t.extract::()?; + geometry::Topology::parse(&s) + .ok_or_else(|| PyValueError::new_err(format!("unknown topology: {s:?}")))? + } + None => geometry::Topology::TriangleList, + }; let geometry = geometry_create(topology).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity: geometry }) } +} +#[pymethods] +impl Geometry { #[pyo3(signature = (*args))] pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { let v = extract_vec4(args)?; @@ -279,6 +752,31 @@ impl Geometry { pub fn vertex_count(&self) -> PyResult { geometry_vertex_count(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + + #[staticmethod] + #[pyo3(signature = (radius, sectors=32, stacks=18))] + pub fn sphere(radius: f32, sectors: u32, stacks: u32) -> PyResult { + let entity = geometry_sphere(radius, sectors, stacks) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[staticmethod] + pub fn r#box(width: f32, height: f32, depth: f32) -> PyResult { + let entity = geometry_box(width, height, depth) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + /// lattice centered at the origin; topology is `POINTS`, intended as a + /// position source for `Particles(geometry=...)` rather than rasterized. + #[staticmethod] + #[pyo3(signature = (nx, ny, nz, spacing=1.0))] + pub fn grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> PyResult { + let entity = geometry_grid(nx, ny, nz, spacing) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } } #[pyclass(unsendable)] @@ -289,6 +787,9 @@ pub struct Graphics { pub width: u32, #[pyo3(get)] pub height: u32, + /// Cached `pixels` list populated by `load_pixels()` and written back by + /// `update_pixels()`, mirroring Processing's `pixels[]`. + pixel_cache: Mutex>>, } impl Drop for Graphics { @@ -297,9 +798,63 @@ impl Drop for Graphics { } } +impl Graphics { + /// Create an offscreen graphics buffer in the already-running app (no window, + /// no `init`). Backs `create_graphics()` / `new_offscreen()`. The caller must + /// ensure the app exists (i.e. `size()` was called first). + pub(crate) fn wrap_offscreen(width: u32, height: u32, hdr: bool) -> PyResult { + let texture_format = if hdr { + TextureFormat::Rgba16Float + } else { + TextureFormat::Rgba8UnormSrgb + }; + let surface_entity = surface_create_offscreen(width, height, 1.0, texture_format) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Self::from_surface(surface_entity, width, height, texture_format, None) + } + + /// Wrap an existing surface entity in a `Graphics` (camera + draw state). The + /// `glfw_ctx` is `None` for offscreen and secondary windows — the main + /// surface owns the shared `GlfwContext` that drives every window. + pub(crate) fn from_surface( + surface_entity: Entity, + width: u32, + height: u32, + texture_format: TextureFormat, + glfw_ctx: Option, + ) -> PyResult { + let graphics = graphics_create(surface_entity, width, height, texture_format) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { + entity: graphics, + surface: Surface { + entity: surface_entity, + glfw_ctx, + }, + width, + height, + pixel_cache: Mutex::new(None), + }) + } + + /// Wrap a secondary window's surface in a `Graphics` (HDR window target). The + /// window lives on the main surface's shared `GlfwContext`, so this holds no + /// `glfw_ctx` of its own. + pub(crate) fn wrap_window(surface_entity: Entity, width: u32, height: u32) -> PyResult { + Self::from_surface( + surface_entity, + width, + height, + TextureFormat::Rgba16Float, + None, + ) + } +} + #[pymethods] impl Graphics { #[new] + #[allow(clippy::too_many_arguments)] pub fn new( width: u32, height: u32, @@ -307,9 +862,10 @@ impl Graphics { sketch_root_path: &str, sketch_file_name: &str, log_level: Option<&str>, + transparent: bool, ) -> PyResult { - let mut glfw_ctx = - GlfwContext::new(width, height).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let mut glfw_ctx = GlfwContext::new(width, height, transparent) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let mut config = Config::new(); config.set(ConfigKey::AssetRootPath, asset_path.to_string()); @@ -321,7 +877,7 @@ impl Graphics { init(config).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let surface = glfw_ctx - .create_surface(width, height) + .create_surface(width, height, transparent) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; let surface = Surface { @@ -332,20 +888,28 @@ impl Graphics { let graphics = graphics_create(surface.entity, width, height, TextureFormat::Rgba16Float) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + // The Window component (applied each frame by the sync loop) otherwise + // defaults its title; match the GLFW create-time title. + surface_set_title(surface.entity, "Processing".to_string()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity: graphics, surface, width, height, + pixel_cache: Mutex::new(None), }) } #[staticmethod] + #[pyo3(signature = (width, height, asset_path, log_level, hdr=false))] pub fn new_offscreen( width: u32, height: u32, asset_path: &str, log_level: Option<&str>, + hdr: bool, ) -> PyResult { let mut config = Config::new(); config.set(ConfigKey::AssetRootPath, asset_path.to_string()); @@ -353,28 +917,7 @@ impl Graphics { config.set(ConfigKey::LogLevel, level.to_string()); } init(config).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - // todo: allow caller to specify texture format? we use an sRGB format by default since - // it plays well with converting to PNG - let texture_format = TextureFormat::Rgba8UnormSrgb; - - let surface_entity = surface_create_offscreen(width, height, 1.0, texture_format) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - let surface = Surface { - entity: surface_entity, - glfw_ctx: None, - }; - - let graphics = graphics_create(surface.entity, width, height, texture_format) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - Ok(Self { - entity: graphics, - surface, - width, - height, - }) + Self::wrap_offscreen(width, height, hdr) } #[getter] @@ -432,6 +975,86 @@ impl Graphics { Ok(png_buf) } + /// Save the current canvas to a PNG file (Processing `save`). + pub fn save(&self, filename: &str) -> PyResult<()> { + let raw = graphics_readback_raw(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let rgba = match raw.format { + TextureFormat::Rgba8UnormSrgb => raw.bytes, + _ => { + let pixels = graphics_readback(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + pixels + .iter() + .flat_map(|pixel| Srgba::from(*pixel).to_u8_array()) + .collect() + } + }; + write_png_file(filename, raw.width, raw.height, &rgba) + } + + /// Read a single pixel as a `Color` (Processing `get`). + pub fn get(&self, x: u32, y: u32) -> PyResult { + if x >= self.width || y >= self.height { + return Err(PyValueError::new_err(format!( + "pixel ({x}, {y}) out of bounds for {}x{} canvas", + self.width, self.height + ))); + } + let pixels = + graphics_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let p = pixels + .get((y * self.width + x) as usize) + .ok_or_else(|| PyValueError::new_err("pixel out of bounds"))?; + Ok(crate::color::PyColor(bevy::prelude::Color::LinearRgba(*p))) + } + + /// Write a single pixel (Processing `set`). + #[pyo3(signature = (x, y, *args))] + pub fn set(&self, x: u32, y: u32, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; + graphics_update_region(self.entity, x, y, 1, 1, &[LinearRgba::from(color)]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Read all pixels into the `pixels` list (Processing `loadPixels`). + pub fn load_pixels(&self, py: Python<'_>) -> PyResult> { + let pixels = + graphics_readback(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let list = pixels_to_pylist(py, &pixels)?; + *self.pixel_cache.lock().unwrap() = Some(list.clone_ref(py)); + Ok(list) + } + + /// The pixel buffer as a list of `Color` (Processing `pixels[]`). Auto-loads + /// on first access if `load_pixels()` has not been called. + #[getter] + pub fn pixels(&self, py: Python<'_>) -> PyResult> { + let cached = self + .pixel_cache + .lock() + .unwrap() + .as_ref() + .map(|list| list.clone_ref(py)); + match cached { + Some(list) => Ok(list), + None => self.load_pixels(py), + } + } + + /// Write the `pixels` list back to the canvas (Processing `updatePixels`). + /// If `pixels` is omitted, the cached buffer from `load_pixels()` is used. + #[pyo3(signature = (pixels=None))] + pub fn update_pixels(&self, py: Python<'_>, pixels: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let seq = resolve_pixels_arg(py, pixels, &self.pixel_cache)?; + let data = pyseq_to_pixels(&seq)?; + graphics_update(self.entity, &data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn poll_for_sketch_update(&self) -> PyResult { match poll_for_sketch_updates().map_err(|_| PyRuntimeError::new_err("SKETCH UPDATE ERR"))? { Some(sketch) => Ok(Sketch { @@ -443,6 +1066,18 @@ impl Graphics { } } + #[pyo3(signature = (kind, *args, **kwargs))] + pub fn filter( + &self, + kind: Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + let filter = crate::filter::resolve_filter(&kind, args, kwargs)?; + graphics_apply_filter(self.entity, filter) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[pyo3(signature = (*args))] pub fn color(&self, args: &Bound<'_, PyTuple>) -> PyResult { extract_color_with_mode( @@ -469,8 +1104,22 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn clear(&self) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::BackgroundColor(bevy::prelude::Color::NONE), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[pyo3(signature = (*args))] pub fn fill(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + if args.len() == 1 + && let Ok(buf) = args.get_item(0)?.extract::>() + { + return graphics_record_command(self.entity, DrawCommand::FillBuffer(buf.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } let color = extract_color_with_mode( args, &graphics_get_color_mode(self.entity) @@ -506,49 +1155,72 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn rect_mode(&self, mode: u8) -> PyResult<()> { - graphics_record_command( - self.entity, - DrawCommand::RectMode(processing::prelude::ShapeMode::from(mode)), - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn rect_mode(&self, mode: &str) -> PyResult<()> { + let mode = ShapeMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown rect mode: {mode:?}")))?; + graphics_record_command(self.entity, DrawCommand::RectMode(mode)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn ellipse_mode(&self, mode: u8) -> PyResult<()> { - graphics_record_command( - self.entity, - DrawCommand::EllipseMode(processing::prelude::ShapeMode::from(mode)), - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn ellipse_mode(&self, mode: &str) -> PyResult<()> { + let mode = ShapeMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown ellipse mode: {mode:?}")))?; + graphics_record_command(self.entity, DrawCommand::EllipseMode(mode)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn stroke_cap(&self, cap: u8) -> PyResult<()> { - graphics_record_command( - self.entity, - DrawCommand::StrokeCap(processing::prelude::StrokeCapMode::from(cap)), - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn stroke_cap(&self, cap: &str) -> PyResult<()> { + let cap = StrokeCapMode::parse(cap) + .ok_or_else(|| PyValueError::new_err(format!("unknown stroke cap: {cap:?}")))?; + graphics_record_command(self.entity, DrawCommand::StrokeCap(cap)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn stroke_join(&self, join: u8) -> PyResult<()> { - graphics_record_command( - self.entity, - DrawCommand::StrokeJoin(processing::prelude::StrokeJoinMode::from(join)), - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn stroke_join(&self, join: &str) -> PyResult<()> { + let join = StrokeJoinMode::parse(join) + .ok_or_else(|| PyValueError::new_err(format!("unknown stroke join: {join:?}")))?; + graphics_record_command(self.entity, DrawCommand::StrokeJoin(join)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn rect( - &self, - x: f32, - y: f32, - w: f32, - h: f32, - tl: f32, - tr: f32, - br: f32, - bl: f32, - ) -> PyResult<()> { + /// Draws a rectangle. Accepts `(x, y, w, h)`, `(x, y, w, h, radius)` for a + /// uniform corner radius, or `(x, y, w, h, tl, tr, br, bl)` for per-corner + /// radii — matching Processing's `rect()` overloads. + #[pyo3(signature = (*args))] + pub fn rect(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let (x, y, w, h, tl, tr, br, bl) = match args.len() { + 4 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + (x, y, w, h, 0.0, 0.0, 0.0, 0.0) + } + 5 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + let r = args.get_item(4)?.extract()?; + (x, y, w, h, r, r, r, r) + } + 8 => { + let x = args.get_item(0)?.extract()?; + let y = args.get_item(1)?.extract()?; + let w = args.get_item(2)?.extract()?; + let h = args.get_item(3)?.extract()?; + let tl = args.get_item(4)?.extract()?; + let tr = args.get_item(5)?.extract()?; + let br = args.get_item(6)?.extract()?; + let bl = args.get_item(7)?.extract()?; + (x, y, w, h, tl, tr, br, bl) + } + n => { + return Err(pyo3::exceptions::PyTypeError::new_err(format!( + "rect() takes 4, 5, or 8 arguments ({n} given)" + ))); + } + }; graphics_record_command( self.entity, DrawCommand::Rect { @@ -646,8 +1318,10 @@ impl Graphics { h: f32, start: f32, stop: f32, - mode: u8, + mode: &str, ) -> PyResult<()> { + let mode = ArcMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown arc mode: {mode:?}")))?; graphics_record_command( self.entity, DrawCommand::Arc { @@ -657,7 +1331,7 @@ impl Graphics { h, start, stop, - mode: processing::prelude::ArcMode::from(mode), + mode, }, ) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -719,14 +1393,11 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn begin_shape(&self, kind: u8) -> PyResult<()> { - graphics_record_command( - self.entity, - DrawCommand::BeginShape { - kind: processing::prelude::ShapeKind::from(kind), - }, - ) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + pub fn begin_shape(&self, kind: &str) -> PyResult<()> { + let kind = ShapeKind::parse(kind) + .ok_or_else(|| PyValueError::new_err(format!("unknown shape kind: {kind:?}")))?; + graphics_record_command(self.entity, DrawCommand::BeginShape { kind }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } pub fn end_shape(&self, close: bool) -> PyResult<()> { @@ -785,13 +1456,378 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn image(&self, file: &str) -> PyResult { + // --- Font --- + + pub fn load_font(&self, path: &str) -> PyResult { + font_load(path) + .map(|entity| Font { entity }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn create_font(&self, name: &str) -> PyResult { + font_create(name) + .map(|entity| Font { entity }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn list_fonts(&self) -> PyResult> { + font_list().map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (font=None))] + pub fn text_font(&self, font: Option<&Font>) -> PyResult<()> { + graphics_text_font(self.entity, font.map(|f| f.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + // --- Text --- + + #[pyo3(signature = (content, x, y, *args, max_w=None, max_h=None))] + pub fn text( + &self, + content: &str, + x: f32, + y: f32, + args: &Bound<'_, pyo3::types::PyTuple>, + max_w: Option, + max_h: Option, + ) -> PyResult<()> { + // text(content, x, y) or text(content, x, y, z) or text(content, x, y, max_w, max_h) + let (z, mw, mh) = match args.len() { + 0 => (0.0, max_w, max_h), + 1 => { + let z: f32 = args.get_item(0)?.extract()?; + (z, max_w, max_h) + } + 2 => { + let w: f32 = args.get_item(0)?.extract()?; + let h: f32 = args.get_item(1)?.extract()?; + (0.0, Some(w), Some(h)) + } + 3 => { + let z: f32 = args.get_item(0)?.extract()?; + let w: f32 = args.get_item(1)?.extract()?; + let h: f32 = args.get_item(2)?.extract()?; + (z, Some(w), Some(h)) + } + _ => { + return Err(PyRuntimeError::new_err( + "text() takes 3-6 positional arguments", + )); + } + }; + graphics_record_command( + self.entity, + DrawCommand::Text { + content: content.to_string(), + x, + y, + z, + max_w: mw, + max_h: mh, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_style(&self, style: &str) -> PyResult<()> { + use processing::prelude::TextStyle; + let style = TextStyle::parse(style) + .ok_or_else(|| PyValueError::new_err(format!("unknown text style: {style:?}")))?; + graphics_text_style(self.entity, style as u8) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_bounds( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult<(f32, f32, f32, f32)> { + graphics_text_bounds(self.entity, content, x, y, max_w, max_h) + .map(|b| (b[0], b[1], b[2], b[3])) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_weight(&self, weight: f32) -> PyResult<()> { + graphics_text_weight(self.entity, weight) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_variation(&self, tag: &str, value: f32) -> PyResult<()> { + graphics_text_variation(self.entity, tag, value) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn clear_text_variations(&self) -> PyResult<()> { + graphics_clear_text_variations(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Enable/configure an OpenType font feature. + /// text_feature("smcp") -> enable (value=1) + /// text_feature("smcp", True) -> enable (value=1) + /// text_feature("smcp", False) -> disable (value=0) + /// text_feature("salt", 3) -> select alternate 3 + #[pyo3(signature = (tag, value=None))] + pub fn text_feature(&self, tag: &str, value: Option<&Bound<'_, PyAny>>) -> PyResult<()> { + let v: u16 = match value { + None => 1, + Some(val) => { + if let Ok(b) = val.extract::() { + if b { 1 } else { 0 } + } else if let Ok(i) = val.extract::() { + i + } else { + return Err(PyRuntimeError::new_err( + "text_feature value must be bool or int", + )); + } + } + }; + graphics_text_feature(self.entity, tag, v) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn no_text_feature(&self, tag: &str) -> PyResult<()> { + graphics_no_text_feature(self.entity, tag) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn clear_text_features(&self) -> PyResult<()> { + graphics_clear_text_features(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Extract glyph outlines as path commands (one list per glyph). + /// Each command is a tuple: ("M", x, y), ("L", x, y), ("Q", cx, cy, x, y), + /// ("C", cx1, cy1, cx2, cy2, x, y), or ("Z",). + pub fn text_to_paths(&self, content: &str, x: f32, y: f32) -> PyResult>>> { + let paths = graphics_text_to_paths(self.entity, content, x, y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Python::attach(|py| Ok(path_commands_to_py(py, paths))) + } + + /// Extract glyph outlines as per-contour path commands. + /// Each contour (MoveTo...Close sequence) is a separate list. + /// Commands use the same tuple shapes as `text_to_paths`. + pub fn text_to_contours(&self, content: &str, x: f32, y: f32) -> PyResult>>> { + let contours = graphics_text_to_contours(self.entity, content, x, y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Python::attach(|py| Ok(path_commands_to_py(py, contours))) + } + + /// Sample points along text outlines. + /// Returns list of [x, y] points. + #[pyo3(signature = (content, x, y, sample_factor=None))] + pub fn text_to_points( + &self, + content: &str, + x: f32, + y: f32, + sample_factor: Option, + ) -> PyResult> { + graphics_text_to_points(self.entity, content, x, y, sample_factor) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Generate a 3D extruded mesh from text outlines. + pub fn text_to_model(&self, content: &str, x: f32, y: f32, depth: f32) -> PyResult { + let mesh = graphics_text_to_model(self.entity, content, x, y, depth) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = + geometry_create_from_mesh(mesh).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Geometry { entity }) + } + + /// Set per-glyph colors for the next text() call. + /// + /// `colors` is a list of color objects (as built by `color(...)`); they are + /// cycled across the glyphs of the next `text()` call. + pub fn text_glyph_colors(&self, colors: Vec>) -> PyResult<()> { + let colors: Vec = colors.iter().map(|c| c.0).collect(); + graphics_text_glyph_colors(self.entity, colors) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_size(&self, size: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::TextSize(size)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (h, v=None))] + pub fn text_align(&self, h: &str, v: Option<&str>) -> PyResult<()> { + use processing::prelude::{TextAlignH, TextAlignV}; + let h = TextAlignH::parse(h).ok_or_else(|| { + PyValueError::new_err(format!("unknown horizontal text align: {h:?}")) + })?; + let v = match v { + Some(v) => TextAlignV::parse(v).ok_or_else(|| { + PyValueError::new_err(format!("unknown vertical text align: {v:?}")) + })?, + None => TextAlignV::default(), + }; + graphics_record_command(self.entity, DrawCommand::TextAlign { h, v }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_leading(&self, leading: f32) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::TextLeading(leading)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_wrap(&self, mode: &str) -> PyResult<()> { + use processing::prelude::TextWrapMode; + let mode = TextWrapMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown text wrap mode: {mode:?}")))?; + graphics_record_command(self.entity, DrawCommand::TextWrap(mode)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Number of lines `content` wraps to. + pub fn text_line_count(&self, content: &str) -> PyResult { + graphics_text_line_count(self.entity, content) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Per-line info as a list of `(text, (x, y, w, h))` tuples. + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_lines( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult> { + graphics_text_lines(self.entity, content, x, y, max_w, max_h) + .map(|lines| { + lines + .into_iter() + .map(|li| (li.text, (li.rect[0], li.rect[1], li.rect[2], li.rect[3]))) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Per-glyph bounding rects as a list of `(x, y, w, h)` tuples. + #[pyo3(signature = (content, x, y, max_w=None, max_h=None))] + pub fn text_glyph_rects( + &self, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult> { + graphics_text_glyph_rects(self.entity, content, x, y, max_w, max_h) + .map(|glyphs| { + glyphs + .into_iter() + .map(|g| (g.rect[0], g.rect[1], g.rect[2], g.rect[3])) + .collect() + }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_width(&self, content: &str) -> PyResult { + graphics_text_width(self.entity, content) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_ascent(&self) -> PyResult { + graphics_text_ascent(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn text_descent(&self) -> PyResult { + graphics_text_descent(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Loads an image from a file and returns an Image object. + /// + /// The path is relative to the sketch's assets directory. + pub fn load_image(&self, file: &str) -> PyResult { match image_load(file) { - Ok(image) => Ok(Image { entity: image }), + Ok(image) => Ok(Image::wrap(image)), Err(e) => Err(PyRuntimeError::new_err(format!("{e}"))), } } + /// Draws an image to the screen. + /// + /// Optional `d_width` and `d_height` resize the image on screen. If omitted, + /// the image's original dimensions are used. + /// + /// Optional `sx`, `sy`, `s_width`, and `s_height` define a sub-region + /// of the source image to draw, specified in pixels. + /// + /// Affected by `image_mode()`, `tint()`, and the current transform. + #[pyo3(signature = (source, dx, dy, d_width=None, d_height=None, sx=None, sy=None, s_width=None, s_height=None))] + pub fn image( + &self, + source: ImageRef, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + ) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Image { + entity: source.entity, + dx, + dy, + d_width, + d_height, + sx, + sy, + s_width, + s_height, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Sets a tint color applied when drawing images. + /// + /// Accepts the same color arguments as `fill()`. The tint is multiplied + /// with the image's pixel colors. Use `no_tint()` to remove. + #[pyo3(signature = (*args))] + pub fn tint(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let color = extract_color_with_mode( + args, + &graphics_get_color_mode(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + )?; + graphics_record_command(self.entity, DrawCommand::Tint(color)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Removes the current tint color so images draw without color modification. + pub fn no_tint(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::NoTint) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Changes how image position arguments are interpreted. + /// + /// - `CORNER` (default) — `dx`, `dy` is the top-left corner. + /// - `CORNERS` — `dx`, `dy` and `d_width`, `d_height` are opposite corners. + /// - `CENTER` — `dx`, `dy` is the center of the image. + pub fn image_mode(&self, mode: &str) -> PyResult<()> { + let mode = ShapeMode::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown image mode: {mode:?}")))?; + graphics_record_command(self.entity, DrawCommand::ImageMode(mode)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn create_image(&self, width: u32, height: u32) -> PyResult { let size = Extent3d { width, @@ -801,7 +1837,7 @@ impl Graphics { let data = vec![0u8; (width * height * 4) as usize]; let entity = image_create(size, data, TextureFormat::Rgba8UnormSrgb) .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Image { entity }) + Ok(Image::wrap(entity)) } pub fn push_matrix(&self) -> PyResult<()> { @@ -819,15 +1855,188 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn push_style(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::PushStyle) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn pop_style(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::PopStyle) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn push(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::PushStyle) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + graphics_record_command(self.entity, DrawCommand::PushMatrix) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn pop(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::PopStyle) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + graphics_record_command(self.entity, DrawCommand::PopMatrix) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + #[pyo3(signature = (*args))] pub fn translate(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let v = extract_vec2(args)?; + let v = if args.len() == 3 { + extract_vec3(args)? + } else { + extract_vec2(args)?.extend(0.0) + }; graphics_record_command(self.entity, DrawCommand::Translate(v)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } pub fn rotate(&self, angle: f32) -> PyResult<()> { - graphics_record_command(self.entity, DrawCommand::Rotate { angle }) + graphics_record_command( + self.entity, + DrawCommand::Rotate { + angle, + axis: Vec3::Z, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn rotate_x(&self, angle: f32) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Rotate { + angle, + axis: Vec3::X, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn rotate_y(&self, angle: f32) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Rotate { + angle, + axis: Vec3::Y, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn rotate_z(&self, angle: f32) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Rotate { + angle, + axis: Vec3::Z, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (angle, *args))] + pub fn rotate_axis(&self, angle: f32, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let axis = extract_vec3(args)?; + graphics_record_command(self.entity, DrawCommand::Rotate { angle, axis }) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Multiply the current matrix by another (Processing `applyMatrix`). + /// Accepts 6 values (2D affine) or 16 values (3D, row-major). + #[pyo3(signature = (*args))] + pub fn apply_matrix(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let affine = affine_from_matrix_args(args)?; + graphics_record_command(self.entity, DrawCommand::ApplyMatrix(affine)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Replace the current matrix (Processing `setMatrix`). + /// Accepts 6 values (2D affine) or 16 values (3D, row-major). + #[pyo3(signature = (*args))] + pub fn set_matrix(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let affine = affine_from_matrix_args(args)?; + graphics_record_command(self.entity, DrawCommand::ResetMatrix) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + graphics_record_command(self.entity, DrawCommand::ApplyMatrix(affine)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Return the current transformation matrix as 16 floats in row-major order. + pub fn get_matrix(&self) -> PyResult<[f32; 16]> { + let m = graphics_get_matrix(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(m.transpose().to_cols_array()) + } + + /// Screen-space x of a model-space coordinate (Processing `screenX`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_x(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Screen-space y of a model-space coordinate (Processing `screenY`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_y(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Screen-space depth of a model-space coordinate (Processing `screenZ`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn screen_z(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_screen_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.z) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space x of a model-space coordinate (Processing `modelX`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_x(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space y of a model-space coordinate (Processing `modelY`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_y(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space z of a model-space coordinate (Processing `modelZ`). + #[pyo3(signature = (x, y, z=0.0))] + pub fn model_z(&self, x: f32, y: f32, z: f32) -> PyResult { + graphics_model_point(self.entity, Vec3::new(x, y, z)) + .map(|p| p.z) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space x of a screen coordinate at the given depth (Processing `worldX`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_x(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.x) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space y of a screen coordinate at the given depth (Processing `worldY`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_y(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.y) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// World-space z of a screen coordinate at the given depth (Processing `worldZ`). + #[pyo3(signature = (sx, sy, depth=0.0))] + pub fn world_z(&self, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics_world_from_screen(self.entity, sx, sy, depth) + .map(|p| p.z) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -970,14 +2179,47 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn use_material(&self, material: &crate::material::Material) -> PyResult<()> { + #[pyo3(signature = (particles, geometry = None, topology = None))] + pub fn particles( + &self, + particles: &crate::particles::Particles, + geometry: Option<&Geometry>, + topology: Option<&str>, + ) -> PyResult<()> { + let topology = match topology { + Some(s) => geometry::Topology::parse(s).ok_or_else(|| { + PyValueError::new_err(format!("particles(): unknown topology {s:?}")) + })?, + None => geometry::Topology::PointList, + }; + graphics_record_command( + self.entity, + DrawCommand::Particles { + particles: particles.entity, + geometry: geometry.map(|g| g.entity), + topology, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn material(&self, material: &crate::material::Material) -> PyResult<()> { graphics_record_command(self.entity, DrawCommand::Material(material.entity)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } #[pyo3(signature = (*args))] pub fn scale(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let v = extract_vec2(args)?; + let v = if args.len() == 3 { + extract_vec3(args)? + } else if args.len() == 1 { + match args.get_item(0)?.extract::() { + Ok(s) => Vec3::splat(s), + Err(_) => extract_vec2(args)?.extend(1.0), + } + } else { + extract_vec2(args)?.extend(1.0) + }; graphics_record_command(self.entity, DrawCommand::Scale(v)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } @@ -997,22 +2239,108 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - pub fn set_material(&self, material: &crate::material::Material) -> PyResult<()> { - graphics_record_command(self.entity, DrawCommand::Material(material.entity)) - .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + #[pyo3(signature = (intensity, threshold=0.0))] + pub fn bloom(&self, intensity: f32, threshold: f32) -> PyResult<()> { + if intensity <= 0.0 { + graphics_remove_bloom(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } else { + graphics_set_bloom(self.entity, intensity, threshold) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + } + + /// Composites a source onto this graphics with a blend mode (Processing + /// `blend`). `src` is any sampleable input — an `Image`, webcam, or another + /// `Graphics` (which is flushed first). Optional `src_rect`/`dst_rect` are + /// `(x, y, w, h)` pixel regions (default full extent); the source is scaled + /// into the dst region. + #[pyo3(signature = (src, mode, *, src_rect=None, dst_rect=None, opacity=1.0))] + pub fn blend( + &self, + src: &Bound<'_, PyAny>, + mode: &PyBlendMode, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + opacity: f32, + ) -> PyResult<()> { + let code = mode.composite_mode().ok_or_else(|| { + PyValueError::new_err( + "blend(): custom blend modes are unsupported here; use a named mode \ + (BLEND, ADD, SUBTRACT, DARKEST, LIGHTEST, DIFFERENCE, EXCLUSION, \ + MULTIPLY, SCREEN, REPLACE)", + ) + })?; + let (src_entity, flush) = resolve_composite_source(src)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + let sr = normalize_src_rect(src_entity, src_rect)?; + let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); + composite_apply(self.entity, src_entity, code, sr, dr, opacity) + } + + /// Copies a source onto this graphics, replacing pixels (Processing `copy`). + /// `src` may be an `Image`, webcam, or `Graphics`. See `blend` for the + /// `src_rect`/`dst_rect` semantics. + #[pyo3(signature = (src, *, src_rect=None, dst_rect=None))] + pub fn copy( + &self, + src: &Bound<'_, PyAny>, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + ) -> PyResult<()> { + let (src_entity, flush) = resolve_composite_source(src)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + let sr = normalize_src_rect(src_entity, src_rect)?; + let dr = normalize_rect(dst_rect, self.width as f32, self.height as f32); + composite_apply(self.entity, src_entity, COMPOSITE_MODE_REPLACE, sr, dr, 1.0) + } + + /// Feedback pass: re-samples this graphics' previous frame with a + /// zoom/rotate/offset transform and a per-frame `decay`, writing it back. + /// On a context you don't clear each frame, call this at the start of + /// `draw()` and then draw new content on top to get feedback trails. + #[pyo3(signature = (*, decay=0.95, zoom=1.0, angle=0.0, offset=(0.0, 0.0)))] + pub fn feedback(&self, decay: f32, zoom: f32, angle: f32, offset: (f32, f32)) -> PyResult<()> { + use shader_value::ShaderValue; + let filter = filter_feedback().map_err(rt_err)?; + filter_set(filter, "decay", ShaderValue::Float(decay)).map_err(rt_err)?; + filter_set(filter, "zoom", ShaderValue::Float(zoom)).map_err(rt_err)?; + filter_set(filter, "angle", ShaderValue::Float(angle)).map_err(rt_err)?; + filter_set(filter, "offset", ShaderValue::Float2([offset.0, offset.1])).map_err(rt_err)?; + graphics_apply_filter(self.entity, filter).map_err(rt_err) + } + + /// Applies a mask source's brightness as this graphics' alpha channel + /// (Processing `mask`). `mask` may be an `Image`, webcam, or `Graphics`. + pub fn mask(&self, mask: &Bound<'_, PyAny>) -> PyResult<()> { + let (src_entity, flush) = resolve_composite_source(mask)?; + if let Some(g) = flush { + graphics_flush(g).map_err(rt_err)?; + } + composite_apply( + self.entity, + src_entity, + COMPOSITE_MODE_MASK, + [0.0, 0.0, 1.0, 1.0], + [0.0, 0.0, 1.0, 1.0], + 1.0, + ) } #[pyo3(name = "color_mode", signature = (mode, max1=None, max2=None, max3=None, max_alpha=None))] pub fn set_color_mode<'py>( &self, - mode: u8, + mode: &str, max1: Option<&Bound<'py, PyAny>>, max2: Option<&Bound<'py, PyAny>>, max3: Option<&Bound<'py, PyAny>>, max_alpha: Option<&Bound<'py, PyAny>>, ) -> PyResult<()> { - let space = crate::color::ColorSpace::from_u8(mode) - .ok_or_else(|| PyRuntimeError::new_err(format!("unknown color space: {mode}")))?; + let space = crate::color::ColorSpace::parse(mode) + .ok_or_else(|| PyValueError::new_err(format!("unknown color space: {mode:?}")))?; let parse = |obj: &Bound<'py, PyAny>, ch: usize| crate::color::parse_numeric(&space, obj, ch); let new_mode = match (max1, max2, max3, max_alpha) { diff --git a/crates/processing_pyo3/src/input.rs b/crates/processing_pyo3/src/input.rs index de40a2ef..1126d3fb 100644 --- a/crates/processing_pyo3/src/input.rs +++ b/crates/processing_pyo3/src/input.rs @@ -38,10 +38,10 @@ pub fn mouse_button() -> PyResult> { processing::prelude::input_mouse_button() .map(|opt| { opt.map(|b| match b { - MouseButton::Left => "LEFT".to_string(), - MouseButton::Right => "RIGHT".to_string(), - MouseButton::Middle => "CENTER".to_string(), - _ => format!("{b:?}"), + MouseButton::Left => constants::LEFT.to_string(), + MouseButton::Right => constants::RIGHT.to_string(), + MouseButton::Middle => constants::CENTER.to_string(), + _ => format!("{b:?}").to_lowercase(), }) }) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index 79029796..f8378bbe 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -9,8 +9,11 @@ //! To allow Python users to create a similar experience, we provide module-level //! functions that forward to a singleton Graphics object pub(crate) behind the scenes. pub(crate) mod color; +pub(crate) mod compute; +mod constants; #[cfg(feature = "cuda")] pub(crate) mod cuda; +pub(crate) mod filter; mod glfw; mod gltf; mod graphics; @@ -19,14 +22,16 @@ pub(crate) mod material; pub(crate) mod math; mod midi; mod monitor; +pub(crate) mod particles; pub(crate) mod shader; mod surface; mod time; #[cfg(feature = "webcam")] mod webcam; +use compute::{Buffer, Compute}; use graphics::{ - Geometry, Graphics, Image, Light, PyBlendMode, Topology, get_graphics, get_graphics_mut, + Font, Geometry, Graphics, Image, Light, PyBlendMode, Sampler, get_graphics, get_graphics_mut, }; use material::Material; @@ -34,11 +39,73 @@ use pyo3::{ BoundObject, exceptions::PyRuntimeError, prelude::*, - types::{PyDict, PyTuple}, + types::{PyDict, PyList, PyTuple}, }; use shader::Shader; use std::ffi::{CStr, CString}; +/// Register a window `Graphics` in the module's `_windows` list so the run loop +/// draws + presents it each frame. +fn register_window(module: &Bound<'_, PyModule>, window: &Py) -> PyResult<()> { + let list = match module.getattr("_windows") { + Ok(existing) if !existing.is_none() => existing + .cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + _ => { + let list = PyList::empty(module.py()); + module.setattr("_windows", &list)?; + list + } + }; + list.append(window)?; + Ok(()) +} + +/// All window `Graphics` the run loop should drive: the main canvas (`_graphics`) +/// followed by any `create_window` results (`_windows`). +fn collect_windows(module: &Bound<'_, PyModule>) -> PyResult>> { + let mut out = Vec::new(); + if let Ok(main) = module.getattr("_graphics") + && !main.is_none() + { + out.push( + main.cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))? + .unbind(), + ); + } + if let Ok(windows) = module.getattr("_windows") + && let Ok(list) = windows.cast_into::() + { + for item in list.iter() { + out.push( + item.cast_into::() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))? + .unbind(), + ); + } + } + Ok(out) +} + +/// Replace the first run of `#` characters in `pattern` with a zero-padded frame +/// number (Processing `saveFrame` semantics, e.g. `frame-####.png`). +fn substitute_frame_number(pattern: &str, n: u32) -> String { + match pattern.find('#') { + Some(start) => { + let hashes = pattern[start..].chars().take_while(|c| *c == '#').count(); + format!( + "{}{:0width$}{}", + &pattern[..start], + n, + &pattern[start + hashes..], + width = hashes + ) + } + None => pattern.to_string(), + } +} + use bevy::log::warn; use gltf::Gltf; use std::cell::{Cell, RefCell}; @@ -116,13 +183,12 @@ pub(crate) fn reset_tracked_globals() { fn sync_globals(module: &Bound<'_, PyModule>, globals: &Bound<'_, PyAny>) -> PyResult<()> { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - input::sync_globals( - globals, - graphics.surface.entity, - graphics.width, - graphics.height, - )?; - surface::sync_globals(globals, &graphics.surface, graphics.width, graphics.height)?; + let width = ::processing::prelude::surface_width(graphics.surface.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let height = ::processing::prelude::surface_height(graphics.surface.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + input::sync_globals(globals, graphics.surface.entity, width, height)?; + surface::sync_globals(globals, &graphics.surface, width, height)?; time::sync_globals(globals)?; Ok(()) } @@ -167,7 +233,12 @@ fn dispatch_event_callbacks(locals: &Bound<'_, PyAny>) -> PyResult<()> { Ok(()) } -fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32) -> PyResult<()> { +fn create_graphics_context( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + transparent: bool, +) -> PyResult<()> { let py = module.py(); let env = detect_environment(py)?; @@ -186,7 +257,8 @@ fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32 match env.as_str() { "jupyter" => { let asset_path = get_asset_root()?; - let graphics = Graphics::new_offscreen(width, height, asset_path.as_str(), log_level)?; + let graphics = + Graphics::new_offscreen(width, height, asset_path.as_str(), log_level, false)?; module.setattr("_graphics", graphics)?; if !has_existing { @@ -206,6 +278,7 @@ fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32 sketch_root.as_str(), sketch_file.as_str(), log_level, + transparent, )?; module.setattr("_graphics", graphics)?; @@ -231,6 +304,7 @@ fn create_graphics_context(module: &Bound<'_, PyModule>, width: u32, height: u32 sketch_root.as_str(), sketch_file.as_str(), log_level, + transparent, )?; module.setattr("_graphics", graphics)?; } @@ -246,7 +320,7 @@ fn ensure_graphics(module: &Bound<'_, PyModule>) -> PyResult<()> { if get_graphics(module)?.is_some() { return Ok(()); } - create_graphics_context(module, DEFAULT_WIDTH, DEFAULT_HEIGHT) + create_graphics_context(module, DEFAULT_WIDTH, DEFAULT_HEIGHT, false) } macro_rules! graphics { @@ -325,6 +399,12 @@ fn detect_environment(py: Python<'_>) -> PyResult { mod mewnala { use super::*; + #[pymodule_export] + use super::Buffer; + #[pymodule_export] + use super::Compute; + #[pymodule_export] + use super::Font; #[pymodule_export] use super::Geometry; #[pymodule_export] @@ -340,289 +420,48 @@ mod mewnala { #[pymodule_export] use super::PyBlendMode; #[pymodule_export] + use super::Sampler; + #[pymodule_export] use super::Shader; #[pymodule_export] - use super::Topology; + use super::color::PyColor; #[cfg(feature = "cuda")] #[pymodule_export] use super::cuda::CudaImage; #[pymodule_export] - use super::monitor::Monitor; - #[pymodule_export] - use super::surface::Surface; - - // Stroke cap/join - #[pymodule_export] - const ROUND: u8 = 0; - #[pymodule_export] - const SQUARE: u8 = 1; - #[pymodule_export] - const PROJECT: u8 = 2; - #[pymodule_export] - const MITER: u8 = 1; - #[pymodule_export] - const BEVEL: u8 = 2; - - // Shape kinds - #[pymodule_export] - const POLYGON: u8 = 0; - #[pymodule_export] - const POINTS: u8 = 1; - #[pymodule_export] - const LINES: u8 = 2; - #[pymodule_export] - const TRIANGLES: u8 = 3; - #[pymodule_export] - const TRIANGLE_FAN: u8 = 4; - #[pymodule_export] - const TRIANGLE_STRIP: u8 = 5; - #[pymodule_export] - const QUADS: u8 = 6; - #[pymodule_export] - const QUAD_STRIP: u8 = 7; - - // Shape modes - #[pymodule_export] - const CORNER: u8 = 0; - #[pymodule_export] - const CORNERS: u8 = 1; - // CENTER = 1 - #[pymodule_export] - const RADIUS: u8 = 3; - - // Arc modes - #[pymodule_export] - const OPEN: u8 = 0; - #[pymodule_export] - const CHORD: u8 = 1; - #[pymodule_export] - const PIE: u8 = 2; - - #[pymodule_export] - const CLOSE: bool = true; - - // Mouse buttons - #[pymodule_export] - const LEFT: u8 = 0; - #[pymodule_export] - const CENTER: u8 = 1; - #[pymodule_export] - const RIGHT: u8 = 2; - - // Letters - #[pymodule_export] - const KEY_A: u32 = 65; - #[pymodule_export] - const KEY_B: u32 = 66; - #[pymodule_export] - const KEY_C: u32 = 67; - #[pymodule_export] - const KEY_D: u32 = 68; - #[pymodule_export] - const KEY_E: u32 = 69; - #[pymodule_export] - const KEY_F: u32 = 70; - #[pymodule_export] - const KEY_G: u32 = 71; - #[pymodule_export] - const KEY_H: u32 = 72; - #[pymodule_export] - const KEY_I: u32 = 73; - #[pymodule_export] - const KEY_J: u32 = 74; - #[pymodule_export] - const KEY_K: u32 = 75; - #[pymodule_export] - const KEY_L: u32 = 76; - #[pymodule_export] - const KEY_M: u32 = 77; - #[pymodule_export] - const KEY_N: u32 = 78; - #[pymodule_export] - const KEY_O: u32 = 79; - #[pymodule_export] - const KEY_P: u32 = 80; - #[pymodule_export] - const KEY_Q: u32 = 81; - #[pymodule_export] - const KEY_R: u32 = 82; - #[pymodule_export] - const KEY_S: u32 = 83; - #[pymodule_export] - const KEY_T: u32 = 84; - #[pymodule_export] - const KEY_U: u32 = 85; - #[pymodule_export] - const KEY_V: u32 = 86; - #[pymodule_export] - const KEY_W: u32 = 87; - #[pymodule_export] - const KEY_X: u32 = 88; - #[pymodule_export] - const KEY_Y: u32 = 89; - #[pymodule_export] - const KEY_Z: u32 = 90; - - // Digits - #[pymodule_export] - const KEY_0: u32 = 48; - #[pymodule_export] - const KEY_1: u32 = 49; - #[pymodule_export] - const KEY_2: u32 = 50; - #[pymodule_export] - const KEY_3: u32 = 51; - #[pymodule_export] - const KEY_4: u32 = 52; - #[pymodule_export] - const KEY_5: u32 = 53; - #[pymodule_export] - const KEY_6: u32 = 54; - #[pymodule_export] - const KEY_7: u32 = 55; - #[pymodule_export] - const KEY_8: u32 = 56; - #[pymodule_export] - const KEY_9: u32 = 57; - - // Punctuation/symbols - #[pymodule_export] - const SPACE: u32 = 32; - #[pymodule_export] - const QUOTE: u32 = 39; - #[pymodule_export] - const COMMA: u32 = 44; - #[pymodule_export] - const MINUS: u32 = 45; - #[pymodule_export] - const PERIOD: u32 = 46; - #[pymodule_export] - const SLASH: u32 = 47; - #[pymodule_export] - const SEMICOLON: u32 = 59; - #[pymodule_export] - const EQUAL: u32 = 61; - #[pymodule_export] - const BRACKET_LEFT: u32 = 91; - #[pymodule_export] - const BACKSLASH: u32 = 92; - #[pymodule_export] - const BRACKET_RIGHT: u32 = 93; - #[pymodule_export] - const BACKQUOTE: u32 = 96; - - // Navigation/editing - #[pymodule_export] - const ESCAPE: u32 = 256; - #[pymodule_export] - const ENTER: u32 = 257; - #[pymodule_export] - const TAB: u32 = 258; - #[pymodule_export] - const BACKSPACE: u32 = 259; - #[pymodule_export] - const INSERT: u32 = 260; - #[pymodule_export] - const DELETE: u32 = 261; - #[pymodule_export] - const UP: u32 = 265; - #[pymodule_export] - const DOWN: u32 = 264; - #[pymodule_export] - const LEFT_ARROW: u32 = 263; - #[pymodule_export] - const RIGHT_ARROW: u32 = 262; - #[pymodule_export] - const PAGE_UP: u32 = 266; - #[pymodule_export] - const PAGE_DOWN: u32 = 267; - #[pymodule_export] - const HOME: u32 = 268; - #[pymodule_export] - const END: u32 = 269; - - // Modifiers - #[pymodule_export] - const SHIFT: u32 = 340; - #[pymodule_export] - const CONTROL: u32 = 341; - #[pymodule_export] - const ALT: u32 = 342; - #[pymodule_export] - const SUPER: u32 = 343; - - // Function keys - #[pymodule_export] - const F1: u32 = 290; - #[pymodule_export] - const F2: u32 = 291; - #[pymodule_export] - const F3: u32 = 292; - #[pymodule_export] - const F4: u32 = 293; - #[pymodule_export] - const F5: u32 = 294; + use super::math::PyQuat; #[pymodule_export] - const F6: u32 = 295; + use super::math::PyVec2; #[pymodule_export] - const F7: u32 = 296; + use super::math::PyVec3; #[pymodule_export] - const F8: u32 = 297; + use super::math::PyVec4; #[pymodule_export] - const F9: u32 = 298; - #[pymodule_export] - const F10: u32 = 299; - #[pymodule_export] - const F11: u32 = 300; - #[pymodule_export] - const F12: u32 = 301; - - // color space constants for color_mode() - #[pymodule_export] - const SRGB: u8 = 0; - #[pymodule_export] - const LINEAR: u8 = 1; - #[pymodule_export] - const HSL: u8 = 2; - #[pymodule_export] - const HSV: u8 = 3; - #[pymodule_export] - const HWB: u8 = 4; + use super::monitor::Monitor; #[pymodule_export] - const OKLAB: u8 = 5; + use super::particles::Attribute; #[pymodule_export] - const OKLCH: u8 = 6; + use super::particles::AttributeFormat; #[pymodule_export] - const LAB: u8 = 7; + use super::particles::Grid; #[pymodule_export] - const LCH: u8 = 8; + use super::particles::Particles; #[pymodule_export] - const XYZ: u8 = 9; + use super::surface::Surface; #[pymodule_init] fn init(module: &Bound<'_, PyModule>) -> PyResult<()> { - use processing::prelude::BlendMode; - - module.add("BLEND", PyBlendMode::from_preset(BlendMode::Blend))?; - module.add("ADD", PyBlendMode::from_preset(BlendMode::Add))?; - module.add("SUBTRACT", PyBlendMode::from_preset(BlendMode::Subtract))?; - module.add("DARKEST", PyBlendMode::from_preset(BlendMode::Darkest))?; - module.add("LIGHTEST", PyBlendMode::from_preset(BlendMode::Lightest))?; - module.add( - "DIFFERENCE", - PyBlendMode::from_preset(BlendMode::Difference), - )?; - module.add("EXCLUSION", PyBlendMode::from_preset(BlendMode::Exclusion))?; - module.add("MULTIPLY", PyBlendMode::from_preset(BlendMode::Multiply))?; - module.add("SCREEN", PyBlendMode::from_preset(BlendMode::Screen))?; - module.add("REPLACE", PyBlendMode::from_preset(BlendMode::Replace))?; - Ok(()) + super::constants::register(module) } #[pymodule] mod math { use super::*; + #[pymodule_export] + use crate::math::PyAffine2; + #[pymodule_export] + use crate::math::PyMat2; #[pymodule_export] use crate::math::PyQuat; #[pymodule_export] @@ -659,77 +498,72 @@ mod mewnala { } } - #[pymodule] - mod color { - use super::*; - - #[pymodule_export] - use crate::color::PyColor; + // top-level so `from mewnala import *` exposes hsva/srgb/etc. directly; + // a `color` submodule would clash with the `color()` function - #[pyfunction] - fn hex(s: &str) -> PyResult { - PyColor::hex(s) - } + #[pyfunction] + fn color_hex(s: &str) -> PyResult { + PyColor::hex(s) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::srgb(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::srgb(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn linear(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::linear(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn linear_rgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::linear(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, l, a=1.0))] - fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { - PyColor::hsla(h, s, l, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, l, a=1.0))] + fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { + PyColor::hsla(h, s, l, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, v, a=1.0))] - fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { - PyColor::hsva(h, s, v, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, v, a=1.0))] + fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { + PyColor::hsva(h, s, v, a) + } - #[pyfunction] - #[pyo3(signature = (h, w, b, a=1.0))] - fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { - PyColor::hwba(h, w, b, a) - } + #[pyfunction] + #[pyo3(signature = (h, w, b, a=1.0))] + fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { + PyColor::hwba(h, w, b, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::oklab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::oklab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::oklch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::oklch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::lab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::lab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::lch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::lch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (x, y, z, a=1.0))] - fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { - PyColor::xyz(x, y, z, a) - } + #[pyfunction] + #[pyo3(signature = (x, y, z, a=1.0))] + fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { + PyColor::xyz(x, y, z, a) } #[cfg(feature = "webcam")] @@ -980,9 +814,14 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module)] - fn size(module: &Bound<'_, PyModule>, width: u32, height: u32) -> PyResult<()> { - create_graphics_context(module, width, height)?; + #[pyo3(pass_module, signature = (width, height, *, transparent=false))] + fn size( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + transparent: bool, + ) -> PyResult<()> { + create_graphics_context(module, width, height, transparent)?; let py = module.py(); let sys = PyModule::import(py, "sys")?; @@ -1004,7 +843,7 @@ mod mewnala { return Ok(()); } - Python::attach(|py| { + let result: PyResult<()> = Python::attach(|py| { let builtins = PyModule::import(py, "builtins")?; let locals = builtins.getattr("locals")?.call0()?; @@ -1104,28 +943,36 @@ mod mewnala { } first_frame = false; - get_graphics_mut(module)? - .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? - .begin_draw()?; - processing::prelude::advance_frame_count() .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + // One draw() per frame drives every window. Globals target the + // primary window; any additional window is drawn via its own + // Graphics methods, exactly like an offscreen buffer. Each window + // is begun before draw() and presented after. + let windows = collect_windows(module)?; + for wg in &windows { + wg.bind(py).borrow().begin_draw()?; + } sync_globals(module, &globals)?; - draw_fn_ref .call0() .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - get_graphics(module)? - .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? - .end_draw()?; + for wg in &windows { + wg.bind(py).borrow().end_draw()?; + } update_loop_state(|s| s.redraw_requested = false); } Ok(()) - }) + }); + + // tear down the app while the thread-local is still alive; the eager + // TLS destructor aborts inside a Bevy resource drop + let _ = ::processing::exit(0); + + result } #[pyfunction] @@ -1224,6 +1071,30 @@ mod mewnala { graphics!(module).pop_matrix() } + #[pyfunction] + #[pyo3(pass_module)] + fn push_style(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).push_style() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn pop_style(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).pop_style() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn push(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).push() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn pop(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).pop() + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn translate(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { @@ -1236,10 +1107,55 @@ mod mewnala { graphics!(module).rotate(angle) } - #[pyfunction(name = "box")] + #[pyfunction] + #[pyo3(pass_module)] + fn rotate_x(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_x(angle) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn rotate_y(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_y(angle) + } + + #[pyfunction] #[pyo3(pass_module)] - fn draw_box(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult<()> { - graphics!(module).draw_box(x, y, z) + fn rotate_z(module: &Bound<'_, PyModule>, angle: f32) -> PyResult<()> { + graphics!(module).rotate_z(angle) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (angle, *args))] + fn rotate_axis( + module: &Bound<'_, PyModule>, + angle: f32, + args: &Bound<'_, PyTuple>, + ) -> PyResult<()> { + graphics!(module).rotate_axis(angle, args) + } + + #[pyfunction(name = "box")] + #[pyo3(pass_module, signature = (*args))] + fn draw_box(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + let (w, h, d) = match args.len() { + 1 => { + let s: f32 = args.get_item(0)?.extract()?; + (s, s, s) + } + 3 => { + let w = args.get_item(0)?.extract()?; + let h = args.get_item(1)?.extract()?; + let d = args.get_item(2)?.extract()?; + (w, h, d) + } + n => { + return Err(pyo3::exceptions::PyTypeError::new_err(format!( + "box() takes 1 or 3 arguments ({n} given)" + ))); + } + }; + graphics!(module).draw_box(w, h, d) } #[pyfunction] @@ -1248,6 +1164,25 @@ mod mewnala { graphics!(module).draw_geometry(&*geometry.extract::>()?) } + #[pyfunction] + #[pyo3(pass_module, signature = (particles, geometry = None, topology = None))] + fn particles( + module: &Bound<'_, PyModule>, + particles: &Bound<'_, super::particles::Particles>, + geometry: Option<&Bound<'_, Geometry>>, + topology: Option<&str>, + ) -> PyResult<()> { + let geometry = match geometry { + Some(g) => Some(g.extract::>()?), + None => None, + }; + graphics!(module).particles( + &*particles.extract::>()?, + geometry.as_deref(), + topology, + ) + } + #[pyfunction(name = "color")] #[pyo3(pass_module, signature = (*args))] fn create_color( @@ -1275,11 +1210,17 @@ mod mewnala { } } + #[pyfunction] + #[pyo3(pass_module)] + fn clear(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).clear() + } + #[pyfunction] #[pyo3(pass_module, signature = (mode, max1=None, max2=None, max3=None, max_alpha=None))] fn color_mode<'py>( module: &Bound<'py, PyModule>, - mode: u8, + mode: &str, max1: Option<&Bound<'py, PyAny>>, max2: Option<&Bound<'py, PyAny>>, max3: Option<&Bound<'py, PyAny>>, @@ -1322,13 +1263,13 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module)] - fn stroke_cap(module: &Bound<'_, PyModule>, cap: u8) -> PyResult<()> { + fn stroke_cap(module: &Bound<'_, PyModule>, cap: &str) -> PyResult<()> { graphics!(module).stroke_cap(cap) } #[pyfunction] #[pyo3(pass_module)] - fn stroke_join(module: &Bound<'_, PyModule>, join: u8) -> PyResult<()> { + fn stroke_join(module: &Bound<'_, PyModule>, join: &str) -> PyResult<()> { graphics!(module).stroke_join(join) } @@ -1339,27 +1280,72 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module, signature = (x, y, w, h, tl=0.0, tr=0.0, br=0.0, bl=0.0))] - fn rect( - module: &Bound<'_, PyModule>, - x: f32, - y: f32, - w: f32, - h: f32, - tl: f32, - tr: f32, - br: f32, - bl: f32, - ) -> PyResult<()> { - graphics!(module).rect(x, y, w, h, tl, tr, br, bl) + #[pyo3(pass_module, signature = (intensity, threshold=0.0))] + fn bloom(module: &Bound<'_, PyModule>, intensity: f32, threshold: f32) -> PyResult<()> { + graphics!(module).bloom(intensity, threshold) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn rect(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).rect(args) } + /// Loads an image from a file and returns an Image object. #[pyfunction] #[pyo3(pass_module, signature = (image_file))] - fn image(module: &Bound<'_, PyModule>, image_file: &str) -> PyResult { + fn load_image(module: &Bound<'_, PyModule>, image_file: &str) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.image(image_file) + graphics.load_image(image_file) + } + + /// Draws an image to the screen. + /// + /// Optional `d_width`/`d_height` resize on screen; defaults to the image's + /// original dimensions. Optional `sx`/`sy`/`s_width`/`s_height` select a + /// sub-region of the source image in pixels. + #[pyfunction] + #[pyo3(pass_module, signature = (source, dx, dy, d_width=None, d_height=None, sx=None, sy=None, s_width=None, s_height=None))] + #[allow(clippy::too_many_arguments)] + fn image( + module: &Bound<'_, PyModule>, + source: graphics::ImageRef, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + ) -> PyResult<()> { + graphics!(module).image(source, dx, dy, d_width, d_height, sx, sy, s_width, s_height) + } + + /// Sets a tint color applied when drawing images. + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn tint(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).tint(args) + } + + /// Removes the current tint so images draw without color modification. + #[pyfunction] + #[pyo3(pass_module)] + fn no_tint(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).no_tint() + } + + /// Changes how image position arguments are interpreted. + /// + /// - `CORNER` (default) — `dx`, `dy` is the top-left corner. + /// - `CENTER` — `dx`, `dy` is the center. + /// - `CORNERS` — `dx`, `dy` and `d_width`, `d_height` are opposite corners. + #[pyfunction] + #[pyo3(pass_module)] + fn image_mode(module: &Bound<'_, PyModule>, mode: &str) -> PyResult<()> { + graphics!(module).image_mode(mode) } #[pyfunction] @@ -1370,35 +1356,152 @@ mod mewnala { graphics.create_image(width, height) } + /// Creates an offscreen graphics buffer (Processing `createGraphics`). The + /// returned `Graphics` renders in the current app — draw into it, use it as a + /// composite source (`blend`/`copy`), blit it into an image (`copy_from`), or + /// display it with `image()`. Call `size()` first. #[pyfunction] #[pyo3(pass_module)] - fn create_directional_light( + fn create_graphics( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + ) -> PyResult { + get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; + Graphics::wrap_offscreen(width, height, false) + } + + /// Opens an additional window (libprocessing extension; not in Processing). + /// Returns a window-backed `Graphics` — draw to it with its own methods + /// inside your normal `draw()`, exactly like an offscreen `create_graphics` + /// buffer (globals target the primary window). The run loop presents every + /// window each frame. Call `size()` first. + #[pyfunction] + #[pyo3(pass_module, signature = (width, height, title="Processing", *, transparent=false))] + fn create_window( + module: &Bound<'_, PyModule>, + width: u32, + height: u32, + title: &str, + transparent: bool, + ) -> PyResult> { + // The main surface owns the shared GLFW instance; add a window to it. + let surface_entity = { + let mut main = get_graphics_mut(module)? + .ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; + main.surface.add_window(width, height, transparent, title)? + }; + // The GLFW window is created with the title, but the Window component + // (which the per-frame sync applies) defaults otherwise — set it too. + ::processing::prelude::surface_set_title(surface_entity, title.to_string()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let window = Py::new( + module.py(), + Graphics::wrap_window(surface_entity, width, height)?, + )?; + register_window(module, &window)?; + Ok(window) + } + + /// Creates a GPU particle system (Processing `createParticles`). `attributes` + /// defaults to `position`; other built-in attributes (`velocity`, `color`, + /// `scale`, `life`, `age`, ...) and declared custom ones materialize on + /// demand when you call `buffer("name")`. + #[pyfunction] + #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] + fn create_particles( + capacity: Option, + attributes: Option>>, + geometry: Option<&Geometry>, + ) -> PyResult { + super::particles::Particles::create(capacity, attributes, geometry) + } + + /// Creates a compute pass from a shader (Processing-style `createCompute`). + #[pyfunction] + fn create_compute(shader: &Shader) -> PyResult { + Compute::create(shader) + } + + /// Creates a GPU storage buffer, empty (`size` bytes) or from initial `data`. + #[pyfunction] + #[pyo3(signature = (size=None, data=None))] + fn create_buffer(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + Buffer::create(size, data) + } + + /// Creates a mesh builder (Processing `createShape`-style). + #[pyfunction] + #[pyo3(signature = (**kwargs))] + fn create_geometry(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + Geometry::create(kwargs) + } + + /// Creates a shader from WGSL/WESL source. + #[pyfunction] + fn create_shader(source: &str) -> PyResult { + Shader::from_source(source) + } + + /// Loads a shader from a file (Processing `loadShader`). + #[pyfunction] + fn load_shader(path: &str) -> PyResult { + Shader::from_path(path) + } + + fn apply_light_transform( + light: &Light, + position: Option, + look_at: Option, + ) -> PyResult<()> { + if let Some(p) = position { + ::processing::prelude::transform_set_position(light.entity, p.into_vec3()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + if let Some(la) = look_at { + ::processing::prelude::transform_look_at(light.entity, la.into_vec3()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (color, illuminance, *, position=None, look_at=None))] + fn directional_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, illuminance: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_directional(color, illuminance) + let light = graphics.light_directional(color, illuminance)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction] - #[pyo3(pass_module)] - fn create_point_light( + #[pyo3(pass_module, signature = (color, intensity, range, radius, *, position=None, look_at=None))] + fn point_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, intensity: f32, range: f32, radius: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_point(color, intensity, range, radius) + let light = graphics.light_point(color, intensity, range, radius)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction] - #[pyo3(pass_module)] - fn create_spot_light( + #[pyo3(pass_module, signature = (color, intensity, range, radius, inner_angle, outer_angle, *, position=None, look_at=None))] + fn spot_light( module: &Bound<'_, PyModule>, color: super::color::ColorLike, intensity: f32, @@ -1406,10 +1509,15 @@ mod mewnala { radius: f32, inner_angle: f32, outer_angle: f32, + position: Option, + look_at: Option, ) -> PyResult { let graphics = get_graphics(module)?.ok_or_else(|| PyRuntimeError::new_err("call size() first"))?; - graphics.light_spot(color, intensity, range, radius, inner_angle, outer_angle) + let light = + graphics.light_spot(color, intensity, range, radius, inner_angle, outer_angle)?; + apply_light_transform(&light, position, look_at)?; + Ok(light) } #[pyfunction(name = "sphere")] @@ -1425,8 +1533,277 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module, signature = (material))] - fn use_material(module: &Bound<'_, PyModule>, material: &Bound<'_, Material>) -> PyResult<()> { - graphics!(module).use_material(&*material.extract::>()?) + fn material(module: &Bound<'_, PyModule>, material: &Bound<'_, Material>) -> PyResult<()> { + graphics!(module).material(&*material.extract::>()?) + } + + /// Creates a new material, mirroring Processing's `createMaterial()`. + /// + /// With no arguments a PBR material is created; pass a `Shader` for a custom + /// material. Any additional keyword arguments (albedo, roughness, metallic, + /// emissive, unlit, ...) are applied immediately. + #[pyfunction] + #[pyo3(signature = (shader=None, **kwargs))] + fn create_material( + shader: Option<&Shader>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult { + Material::new(shader, kwargs) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (kind, *args, **kwargs))] + fn filter( + module: &Bound<'_, PyModule>, + kind: Bound<'_, PyAny>, + args: &Bound<'_, PyTuple>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + graphics!(module).filter(kind, args, kwargs) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (src, mode, *, src_rect=None, dst_rect=None, opacity=1.0))] + fn blend( + module: &Bound<'_, PyModule>, + src: &Bound<'_, PyAny>, + mode: &graphics::PyBlendMode, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + opacity: f32, + ) -> PyResult<()> { + graphics!(module).blend(src, mode, src_rect, dst_rect, opacity) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (src, *, src_rect=None, dst_rect=None))] + fn copy( + module: &Bound<'_, PyModule>, + src: &Bound<'_, PyAny>, + src_rect: Option<[f32; 4]>, + dst_rect: Option<[f32; 4]>, + ) -> PyResult<()> { + graphics!(module).copy(src, src_rect, dst_rect) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn mask(module: &Bound<'_, PyModule>, mask: &Bound<'_, PyAny>) -> PyResult<()> { + graphics!(module).mask(mask) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (*, decay=0.95, zoom=1.0, angle=0.0, offset=(0.0, 0.0)))] + fn feedback( + module: &Bound<'_, PyModule>, + decay: f32, + zoom: f32, + angle: f32, + offset: (f32, f32), + ) -> PyResult<()> { + graphics!(module).feedback(decay, zoom, angle, offset) + } + + // --- Text --- + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, *args, max_w=None, max_h=None))] + fn text( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + args: &Bound<'_, PyTuple>, + max_w: Option, + max_h: Option, + ) -> PyResult<()> { + graphics!(module).text(content, x, y, args, max_w, max_h) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_size(module: &Bound<'_, PyModule>, size: f32) -> PyResult<()> { + graphics!(module).text_size(size) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (h, v=None))] + fn text_align(module: &Bound<'_, PyModule>, h: &str, v: Option<&str>) -> PyResult<()> { + graphics!(module).text_align(h, v) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_leading(module: &Bound<'_, PyModule>, leading: f32) -> PyResult<()> { + graphics!(module).text_leading(leading) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (font=None))] + fn text_font(module: &Bound<'_, PyModule>, font: Option<&Font>) -> PyResult<()> { + graphics!(module).text_font(font) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_style(module: &Bound<'_, PyModule>, style: &str) -> PyResult<()> { + graphics!(module).text_style(style) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_wrap(module: &Bound<'_, PyModule>, mode: &str) -> PyResult<()> { + graphics!(module).text_wrap(mode) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_width(module: &Bound<'_, PyModule>, content: &str) -> PyResult { + graphics!(module).text_width(content) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_ascent(module: &Bound<'_, PyModule>) -> PyResult { + graphics!(module).text_ascent() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_descent(module: &Bound<'_, PyModule>) -> PyResult { + graphics!(module).text_descent() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, max_w=None, max_h=None))] + fn text_bounds( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, + ) -> PyResult<(f32, f32, f32, f32)> { + graphics!(module).text_bounds(content, x, y, max_w, max_h) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_line_count(module: &Bound<'_, PyModule>, content: &str) -> PyResult { + graphics!(module).text_line_count(content) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_weight(module: &Bound<'_, PyModule>, weight: f32) -> PyResult<()> { + graphics!(module).text_weight(weight) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_variation(module: &Bound<'_, PyModule>, tag: &str, value: f32) -> PyResult<()> { + graphics!(module).text_variation(tag, value) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn clear_text_variations(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).clear_text_variations() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (tag, value=None))] + fn text_feature( + module: &Bound<'_, PyModule>, + tag: &str, + value: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + graphics!(module).text_feature(tag, value) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn no_text_feature(module: &Bound<'_, PyModule>, tag: &str) -> PyResult<()> { + graphics!(module).no_text_feature(tag) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn clear_text_features(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).clear_text_features() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_glyph_colors( + module: &Bound<'_, PyModule>, + colors: Vec>, + ) -> PyResult<()> { + graphics!(module).text_glyph_colors(colors) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn load_font(module: &Bound<'_, PyModule>, path: &str) -> PyResult { + graphics!(module).load_font(path) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn create_font(module: &Bound<'_, PyModule>, name: &str) -> PyResult { + graphics!(module).create_font(name) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn list_fonts(module: &Bound<'_, PyModule>) -> PyResult> { + graphics!(module).list_fonts() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_paths( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + graphics!(module).text_to_paths(content, x, y) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_contours( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + ) -> PyResult>>> { + graphics!(module).text_to_contours(content, x, y) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (content, x, y, sample_factor=None))] + fn text_to_points( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + sample_factor: Option, + ) -> PyResult> { + graphics!(module).text_to_points(content, x, y, sample_factor) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn text_to_model( + module: &Bound<'_, PyModule>, + content: &str, + x: f32, + y: f32, + depth: f32, + ) -> PyResult { + graphics!(module).text_to_model(content, x, y, depth) } #[pyfunction] @@ -1514,7 +1891,7 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module, signature = (cx, cy, w, h, start, stop, mode=0))] + #[pyo3(pass_module, signature = (cx, cy, w, h, start, stop, mode=processing::prelude::constants::OPEN))] fn arc( module: &Bound<'_, PyModule>, cx: f32, @@ -1523,7 +1900,7 @@ mod mewnala { h: f32, start: f32, stop: f32, - mode: u8, + mode: &str, ) -> PyResult<()> { graphics!(module).arc(cx, cy, w, h, start, stop, mode) } @@ -1561,8 +1938,8 @@ mod mewnala { } #[pyfunction] - #[pyo3(pass_module, signature = (kind=0))] - fn begin_shape(module: &Bound<'_, PyModule>, kind: u8) -> PyResult<()> { + #[pyo3(pass_module, signature = (kind=processing::prelude::constants::POLYGON))] + fn begin_shape(module: &Bound<'_, PyModule>, kind: &str) -> PyResult<()> { graphics!(module).begin_shape(kind) } @@ -1628,6 +2005,129 @@ mod mewnala { graphics!(module).reset_matrix() } + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn apply_matrix(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).apply_matrix(args) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (*args))] + fn set_matrix(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { + graphics!(module).set_matrix(args) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn get_matrix(module: &Bound<'_, PyModule>) -> PyResult<[f32; 16]> { + graphics!(module).get_matrix() + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_x(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_x(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_y(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_y(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn screen_z(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).screen_z(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_x(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_x(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_y(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_y(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, z=0.0))] + fn model_z(module: &Bound<'_, PyModule>, x: f32, y: f32, z: f32) -> PyResult { + graphics!(module).model_z(x, y, z) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_x(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_x(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_y(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_y(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (sx, sy, depth=0.0))] + fn world_z(module: &Bound<'_, PyModule>, sx: f32, sy: f32, depth: f32) -> PyResult { + graphics!(module).world_z(sx, sy, depth) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn get(module: &Bound<'_, PyModule>, x: u32, y: u32) -> PyResult { + graphics!(module).get(x, y) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (x, y, *args))] + fn set( + module: &Bound<'_, PyModule>, + x: u32, + y: u32, + args: &Bound<'_, PyTuple>, + ) -> PyResult<()> { + graphics!(module).set(x, y, args) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn load_pixels(module: &Bound<'_, PyModule>, py: Python<'_>) -> PyResult> { + graphics!(module).load_pixels(py) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (pixels=None))] + fn update_pixels( + module: &Bound<'_, PyModule>, + py: Python<'_>, + pixels: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + graphics!(module).update_pixels(py, pixels) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn save(module: &Bound<'_, PyModule>, filename: &str) -> PyResult<()> { + graphics!(module).save(filename) + } + + #[pyfunction] + #[pyo3(pass_module, signature = (filename=None))] + fn save_frame(module: &Bound<'_, PyModule>, filename: Option<&str>) -> PyResult<()> { + let count = ::processing::prelude::frame_count() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let name = match filename { + Some(f) => substitute_frame_number(f, count), + None => format!("screen-{count:04}.png"), + }; + graphics!(module).save(&name) + } + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn scale(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { @@ -1648,13 +2148,13 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module)] - fn rect_mode(module: &Bound<'_, PyModule>, mode: u8) -> PyResult<()> { + fn rect_mode(module: &Bound<'_, PyModule>, mode: &str) -> PyResult<()> { graphics!(module).rect_mode(mode) } #[pyfunction] #[pyo3(pass_module)] - fn ellipse_mode(module: &Bound<'_, PyModule>, mode: u8) -> PyResult<()> { + fn ellipse_mode(module: &Bound<'_, PyModule>, mode: &str) -> PyResult<()> { graphics!(module).ellipse_mode(mode) } @@ -1758,6 +2258,14 @@ mod mewnala { fn midi_play_notes(note: u8, duration: u64) -> PyResult<()> { midi::play_notes(note, duration) } + #[pyfunction] + fn midi_note_on(note: u8, velocity: u8) -> PyResult<()> { + midi::note_on(note, velocity) + } + #[pyfunction] + fn midi_note_off(note: u8) -> PyResult<()> { + midi::note_off(note) + } #[pyfunction] fn key_is_down(key_code: u32) -> PyResult { diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 757d2d9b..8e033674 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -1,8 +1,11 @@ use bevy::prelude::Entity; use processing::prelude::*; -use pyo3::types::PyDict; +use pyo3::types::{PyDict, PyInt}; use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use crate::color::PyColor; +use crate::compute::Buffer; +use crate::graphics::ImageRef; use crate::math::{PyVec2, PyVec3, PyVec4}; use crate::shader::Shader; @@ -11,34 +14,44 @@ pub struct Material { pub(crate) entity: Entity, } -fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult { - if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Float(v)); +pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult { + if let Ok(img_ref) = value.extract::() { + return Ok(shader_value::ShaderValue::Texture(img_ref.entity)); } - if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Int(v)); + if let Ok(int_val) = value.cast::() { + if let Ok(v) = int_val.extract::() { + return Ok(shader_value::ShaderValue::Int(v)); + } + if let Ok(v) = int_val.extract::() { + return Ok(shader_value::ShaderValue::UInt(v)); + } + } + if let Ok(v) = value.extract::() { + return Ok(shader_value::ShaderValue::Float(v)); } - // Accept PyVec types if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float4(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float4(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float3(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float3(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float2(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float2(v.0.to_array())); + } + + if let Ok(buf) = value.extract::>() { + return Ok(shader_value::ShaderValue::Buffer(buf.entity)); } - // Fall back to raw arrays if let Ok(v) = value.extract::<[f32; 4]>() { - return Ok(material::MaterialValue::Float4(v)); + return Ok(shader_value::ShaderValue::Float4(v)); } if let Ok(v) = value.extract::<[f32; 3]>() { - return Ok(material::MaterialValue::Float3(v)); + return Ok(shader_value::ShaderValue::Float3(v)); } if let Ok(v) = value.extract::<[f32; 2]>() { - return Ok(material::MaterialValue::Float2(v)); + return Ok(shader_value::ShaderValue::Float2(v)); } Err(PyRuntimeError::new_err(format!( @@ -47,6 +60,58 @@ fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult) -> PyResult<()> { + if let Ok(buf) = value.extract::>() { + return material_set_albedo_buffer(entity, buf.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + let rgba = if let Ok(c) = value.extract::>() { + let srgba: bevy::color::Srgba = c.0.into(); + Some([srgba.red, srgba.green, srgba.blue, srgba.alpha]) + } else if let Ok(rgba) = value.extract::<[f32; 4]>() { + Some(rgba) + } else if let Ok(rgb) = value.extract::<[f32; 3]>() { + Some([rgb[0], rgb[1], rgb[2], 1.0]) + } else { + None + }; + if let Some(rgba) = rgba { + return material_set(entity, "color", shader_value::ShaderValue::Float4(rgba)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + Err(PyRuntimeError::new_err(format!( + "unsupported albedo type: {} (expected Color, Buffer, or [r,g,b,(a)])", + value.get_type().name()? + ))) +} + +fn py_truthy(value: &Bound<'_, PyAny>) -> PyResult { + value + .extract::() + .or_else(|_| value.extract::().map(|f| f > 0.5)) +} + +fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let rt = |e| PyRuntimeError::new_err(format!("{e}")); + match name.as_str() { + "albedo" => apply_albedo(entity, &value)?, + "unlit" => material_set_unlit(entity, py_truthy(&value)?).map_err(rt)?, + "double_sided" => material_set_double_sided(entity, py_truthy(&value)?).map_err(rt)?, + "depth_write" => material_set_depth_write(entity, py_truthy(&value)?).map_err(rt)?, + "alpha_mode" => { + material_set_alpha_mode(entity, value.extract::()?, 0.5).map_err(rt)? + } + _ => { + let v = py_to_shader_value(&value)?; + material_set(entity, &name, v).map_err(rt)?; + } + } + } + Ok(()) +} + #[pymethods] impl Material { #[new] @@ -59,16 +124,31 @@ impl Material { material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))? }; - let mat = Self { entity }; if let Some(kwargs) = kwargs { - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(mat.entity, &name, mat_value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - } + apply_kwargs(entity, kwargs)?; } - Ok(mat) + Ok(Self { entity }) + } + + #[staticmethod] + #[pyo3(signature = (**kwargs))] + pub fn pbr(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; + } + Ok(Self { entity }) + } + + #[staticmethod] + #[pyo3(signature = (**kwargs))] + pub fn unlit(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + material_set_unlit(entity, true).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; + } + Ok(Self { entity }) } #[pyo3(signature = (**kwargs))] @@ -76,13 +156,7 @@ impl Material { let Some(kwargs) = kwargs else { return Ok(()); }; - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(self.entity, &name, mat_value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - } - Ok(()) + apply_kwargs(self.entity, kwargs) } } diff --git a/crates/processing_pyo3/src/math.rs b/crates/processing_pyo3/src/math.rs index 92d3c5cc..81bc61cf 100644 --- a/crates/processing_pyo3/src/math.rs +++ b/crates/processing_pyo3/src/math.rs @@ -1,6 +1,6 @@ use std::hash::{Hash, Hasher}; -use bevy::math::{EulerRot, Quat, Vec2, Vec3, Vec3Swizzles, Vec4, Vec4Swizzles}; +use bevy::math::{Affine2, EulerRot, Mat2, Quat, Vec2, Vec3, Vec4}; use pyo3::{ exceptions::{PyAttributeError, PyTypeError}, prelude::*, @@ -504,6 +504,11 @@ macro_rules! impl_py_vec { } impl_py_vec!(PyVec2, "Vec2", 2, [(x, set_x, 0), (y, set_y, 1)], Vec2, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec2::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec2::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec2::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec2::Y) } + fn angle(&self) -> f32 { self.0.y.atan2(self.0.x) } @@ -543,6 +548,12 @@ impl_py_vec!(PyVec2, "Vec2", 2, [(x, set_x, 0), (y, set_y, 1)], Vec2, extra { }); impl_py_vec!(PyVec3, "Vec3", 3, [(x, set_x, 0), (y, set_y, 1), (z, set_z, 2)], Vec3, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec3::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec3::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec3::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec3::Y) } + #[classattr] #[allow(non_snake_case)] fn Z() -> Self { Self(Vec3::Z) } + fn cross(&self, other: &Self) -> Self { Self(self.0.cross(other.0)) } @@ -570,6 +581,13 @@ impl_py_vec!( [(x, set_x, 0), (y, set_y, 1), (z, set_z, 2), (w, set_w, 3)], Vec4, extra { + #[classattr] #[allow(non_snake_case)] fn ZERO() -> Self { Self(Vec4::ZERO) } + #[classattr] #[allow(non_snake_case)] fn ONE() -> Self { Self(Vec4::ONE) } + #[classattr] #[allow(non_snake_case)] fn X() -> Self { Self(Vec4::X) } + #[classattr] #[allow(non_snake_case)] fn Y() -> Self { Self(Vec4::Y) } + #[classattr] #[allow(non_snake_case)] fn Z() -> Self { Self(Vec4::Z) } + #[classattr] #[allow(non_snake_case)] fn W() -> Self { Self(Vec4::W) } + fn truncate(&self) -> PyVec3 { PyVec3(self.0.truncate()) } @@ -771,6 +789,174 @@ impl PyVecIter { } } +#[pyclass(name = "Mat2", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyMat2(pub(crate) Mat2); + +impl From for PyMat2 { + fn from(m: Mat2) -> Self { + Self(m) + } +} + +#[pymethods] +impl PyMat2 { + #[new] + #[pyo3(signature = (*args))] + pub fn py_new(args: &Bound<'_, PyTuple>) -> PyResult { + match args.len() { + 0 => Ok(Self(Mat2::IDENTITY)), + 4 => { + let m00: f32 = args.get_item(0)?.extract()?; + let m01: f32 = args.get_item(1)?.extract()?; + let m10: f32 = args.get_item(2)?.extract()?; + let m11: f32 = args.get_item(3)?.extract()?; + Ok(Self(Mat2::from_cols( + Vec2::new(m00, m01), + Vec2::new(m10, m11), + ))) + } + _ => Err(PyTypeError::new_err("Mat2 takes 0 or 4 arguments")), + } + } + + #[staticmethod] + fn from_scale(scale: PyVec2) -> Self { + Self(Mat2::from_diagonal(scale.0)) + } + + #[staticmethod] + fn from_angle(angle: f32) -> Self { + Self(Mat2::from_angle(angle)) + } + + fn __mul__(&self, rhs: &Bound<'_, PyAny>) -> PyResult> { + let py = rhs.py(); + if let Ok(other) = rhs.extract::>() { + return Ok(PyMat2(self.0 * other.0) + .into_pyobject(py)? + .into_any() + .unbind()); + } + if let Ok(v) = rhs.extract::>() { + return Ok(PyVec2(self.0 * v.0).into_pyobject(py)?.into_any().unbind()); + } + Err(PyTypeError::new_err( + "unsupported operand type(s) for *: 'Mat2'", + )) + } + + fn determinant(&self) -> f32 { + self.0.determinant() + } + + fn inverse(&self) -> Self { + Self(self.0.inverse()) + } + + fn transpose(&self) -> Self { + Self(self.0.transpose()) + } + + fn __repr__(&self) -> String { + let c = self.0.to_cols_array(); + format!("Mat2({}, {}, {}, {})", c[0], c[1], c[2], c[3]) + } + + fn __str__(&self) -> String { + self.__repr__() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for &c in self.0.to_cols_array().iter() { + hash_f32(c, &mut hasher); + } + std::hash::Hasher::finish(&hasher) + } +} + +#[pyclass(name = "Affine2", from_py_object)] +#[derive(Clone, Debug)] +pub struct PyAffine2(pub(crate) Affine2); + +impl From for PyAffine2 { + fn from(a: Affine2) -> Self { + Self(a) + } +} + +#[pymethods] +impl PyAffine2 { + #[new] + #[pyo3(signature = (matrix=None, translation=None))] + pub fn py_new(matrix: Option>, translation: Option>) -> Self { + Self(Affine2 { + matrix2: matrix.map(|m| m.0).unwrap_or(Mat2::IDENTITY), + translation: translation.map(|t| t.0).unwrap_or(Vec2::ZERO), + }) + } + + #[staticmethod] + fn from_scale(scale: PyVec2) -> Self { + Self(Affine2::from_scale(scale.0)) + } + + #[staticmethod] + fn from_scale_angle_translation(scale: PyVec2, angle: f32, translation: PyVec2) -> Self { + Self(Affine2::from_scale_angle_translation( + scale.0, + angle, + translation.0, + )) + } + + #[getter] + fn matrix(&self) -> PyMat2 { + PyMat2(self.0.matrix2) + } + + #[getter] + fn translation(&self) -> PyVec2 { + PyVec2(self.0.translation) + } + + fn inverse(&self) -> Self { + Self(self.0.inverse()) + } + + fn __repr__(&self) -> String { + let m = self.0.matrix2.to_cols_array(); + let t = self.0.translation; + format!( + "Affine2(Mat2({}, {}, {}, {}), Vec2({}, {}))", + m[0], m[1], m[2], m[3], t.x, t.y + ) + } + + fn __str__(&self) -> String { + self.__repr__() + } + + fn __eq__(&self, other: &Self) -> bool { + self.0 == other.0 + } + + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for &c in self.0.matrix2.to_cols_array().iter() { + hash_f32(c, &mut hasher); + } + hash_f32(self.0.translation.x, &mut hasher); + hash_f32(self.0.translation.y, &mut hasher); + std::hash::Hasher::finish(&hasher) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/processing_pyo3/src/midi.rs b/crates/processing_pyo3/src/midi.rs index 621ea9d9..494b5fee 100644 --- a/crates/processing_pyo3/src/midi.rs +++ b/crates/processing_pyo3/src/midi.rs @@ -16,3 +16,9 @@ pub fn list_ports() -> PyResult> { pub fn play_notes(note: u8, duration: u64) -> PyResult<()> { midi_play_notes(note, duration).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } +pub fn note_on(note: u8, velocity: u8) -> PyResult<()> { + midi_note_on(note, velocity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) +} +pub fn note_off(note: u8) -> PyResult<()> { + midi_note_off(note).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) +} diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs new file mode 100644 index 00000000..5b706f10 --- /dev/null +++ b/crates/processing_pyo3/src/particles.rs @@ -0,0 +1,993 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use processing_render::geometry; +use pyo3::types::PyDict; +use pyo3::{ + exceptions::{PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, +}; +use std::collections::HashMap; + +use processing_render::particles::algebra::{ + combine as algebra_combine, extract as algebra_extract, generate as algebra_generate, + lookup as algebra_lookup, map as algebra_map, mix as algebra_mix, pack as algebra_pack, + reduce_components as algebra_reduce, +}; +use processing_render::particles::compact::compact as compact_indices; + +use crate::compute::{Buffer, Compute}; +use crate::graphics::{Geometry, Image}; + +use processing::prelude::constants as c; +use processing_render::{ + COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, +}; +use processing_render::{GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM}; +use processing_render::{ + MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, + MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, +}; +use processing_render::{ + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, +}; + +fn parse_map_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::AFFINE) => Ok(MAP_AFFINE), + _ if s.eq_ignore_ascii_case(c::ABS) => Ok(MAP_ABS), + _ if s.eq_ignore_ascii_case(c::NEGATE) => Ok(MAP_NEGATE), + _ if s.eq_ignore_ascii_case(c::CLAMP) => Ok(MAP_CLAMP), + _ if s.eq_ignore_ascii_case(c::FLOOR) => Ok(MAP_FLOOR), + _ if s.eq_ignore_ascii_case(c::SQUARE) => Ok(MAP_SQUARE), + _ if s.eq_ignore_ascii_case(c::SQRT) => Ok(MAP_SQRT), + _ if s.eq_ignore_ascii_case(c::GREATER) => Ok(MAP_GREATER), + _ if s.eq_ignore_ascii_case(c::LESS) => Ok(MAP_LESS), + _ if s.eq_ignore_ascii_case(c::GEQ) => Ok(MAP_GEQ), + _ if s.eq_ignore_ascii_case(c::LEQ) => Ok(MAP_LEQ), + _ if s.eq_ignore_ascii_case(c::EQ) => Ok(MAP_EQ), + _ if s.eq_ignore_ascii_case(c::NEQ) => Ok(MAP_NEQ), + _ => Err(PyValueError::new_err(format!("map: unknown op {s:?}"))), + } +} + +fn parse_combine_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::ADD) => Ok(COMBINE_ADD), + _ if s.eq_ignore_ascii_case(c::SUB) => Ok(COMBINE_SUB), + _ if s.eq_ignore_ascii_case(c::MUL) => Ok(COMBINE_MUL), + _ if s.eq_ignore_ascii_case(c::DIV) => Ok(COMBINE_DIV), + _ if s.eq_ignore_ascii_case(c::MIN) => Ok(COMBINE_MIN), + _ if s.eq_ignore_ascii_case(c::MAX) => Ok(COMBINE_MAX), + _ if s.eq_ignore_ascii_case(c::POW) => Ok(COMBINE_POW), + _ => Err(PyValueError::new_err(format!("combine: unknown op {s:?}"))), + } +} + +fn parse_reduce_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::LENGTH) => Ok(REDUCE_LENGTH), + _ if s.eq_ignore_ascii_case(c::SUM) => Ok(REDUCE_SUM), + _ if s.eq_ignore_ascii_case(c::MIN) => Ok(REDUCE_MIN), + _ if s.eq_ignore_ascii_case(c::MAX) => Ok(REDUCE_MAX), + _ if s.eq_ignore_ascii_case(c::SUMSQ) => Ok(REDUCE_SUMSQ), + _ if s.eq_ignore_ascii_case(c::MEAN) => Ok(REDUCE_MEAN), + _ => Err(PyValueError::new_err(format!("reduce: unknown op {s:?}"))), + } +} + +fn parse_generate_mode(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::UNIFORM) => Ok(GEN_UNIFORM), + _ if s.eq_ignore_ascii_case(c::SIGNED) => Ok(GEN_SIGNED), + _ if s.eq_ignore_ascii_case(c::GAUSSIAN) => Ok(GEN_GAUSSIAN), + _ => Err(PyValueError::new_err(format!( + "generate: unknown mode {s:?}" + ))), + } +} + +const NEIGHBOR_SUM: u32 = 0; +const NEIGHBOR_MEAN: u32 = 1; +const NEIGHBOR_COUNT: u32 = 2; + +fn parse_neighbor_op(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::SUM) => Ok(NEIGHBOR_SUM), + _ if s.eq_ignore_ascii_case(c::MEAN) => Ok(NEIGHBOR_MEAN), + _ if s.eq_ignore_ascii_case(c::COUNT) || s.eq_ignore_ascii_case(c::DENSITY) => { + Ok(NEIGHBOR_COUNT) + } + _ => Err(PyValueError::new_err(format!("neighbor: unknown op {s:?}"))), + } +} + +fn parse_falloff(s: &str) -> PyResult { + match () { + _ if s.eq_ignore_ascii_case(c::CONSTANT) => Ok(FALLOFF_CONST), + _ if s.eq_ignore_ascii_case(c::LINEAR) => Ok(FALLOFF_LINEAR), + _ if s.eq_ignore_ascii_case(c::SMOOTHSTEP) => Ok(FALLOFF_SMOOTHSTEP), + _ if s.eq_ignore_ascii_case(c::QUADRATIC) => Ok(FALLOFF_QUADRATIC), + _ if s.eq_ignore_ascii_case(c::CUBIC) => Ok(FALLOFF_CUBIC), + _ if s.eq_ignore_ascii_case(c::INVERSE) => Ok(FALLOFF_INVERSE), + _ => Err(PyValueError::new_err(format!( + "neighbor: unknown falloff {s:?}" + ))), + } +} + +#[pyclass(unsendable)] +pub struct Grid { + pub(crate) inner: processing_render::particles::grid::Grid, +} + +#[pymethods] +impl Grid { + pub fn build(&self, position: &Buffer) -> PyResult<()> { + grid_build(&self.inner, position.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn bind(&self, compute: &Compute) -> PyResult<()> { + grid_bind(&self.inner, compute.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[getter] + pub fn cell_size(&self) -> f32 { + self.inner.params.cell_size + } +} + +static FLOCK_COMPUTE: std::sync::Mutex> = std::sync::Mutex::new(None); + +fn flock_compute() -> PyResult { + let mut guard = FLOCK_COMPUTE.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let e = particles_kernel_flock().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + *guard = Some(e); + Ok(e) +} + +static PHYSICS_COMPUTES: std::sync::Mutex>> = + std::sync::Mutex::new(None); + +fn physics_compute(name: &str) -> PyResult> { + let lower = name.to_ascii_lowercase(); + if let Some(cache) = PHYSICS_COMPUTES.lock().unwrap().as_ref() { + if let Some(&e) = cache.get(&lower) { + return Ok(Some(e)); + } + } + let created = if lower == c::NOISE { + particles_kernel_noise() + } else if lower == c::TRANSFORM { + particles_kernel_transform() + } else if lower == c::ATTRACT { + particles_kernel_attract() + } else if lower == c::DRAG { + particles_kernel_drag() + } else if lower == c::VORTEX { + particles_kernel_vortex() + } else if lower == c::FORCE { + particles_kernel_force() + } else if lower == c::INTEGRATE { + particles_kernel_integrate() + } else if lower == c::AGE { + particles_kernel_age() + } else if lower == c::IMPULSE { + particles_kernel_impulse() + } else if lower == c::ORIENT { + particles_kernel_orient() + } else if lower == c::FIELD { + particles_kernel_field() + } else if lower == c::BOUNDS_SPHERE { + particles_kernel_bounds_sphere() + } else if lower == c::BOUNDS_BOX { + particles_kernel_bounds_box() + } else { + return Ok(None); + }; + let entity = created.map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + PHYSICS_COMPUTES + .lock() + .unwrap() + .get_or_insert_with(HashMap::new) + .insert(lower, entity); + Ok(Some(entity)) +} + +fn kw<'a>(kwargs: Option<&Bound<'a, PyDict>>, key: &str) -> Option> { + kwargs.and_then(|d| d.get_item(key).ok().flatten()) +} + +fn kw_f32(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: f32) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn kw_u32(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: u32) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn kw_bool(kwargs: Option<&Bound<'_, PyDict>>, key: &str, default: bool) -> PyResult { + match kw(kwargs, key) { + Some(v) => v.extract(), + None => Ok(default), + } +} + +fn map_params(kwargs: Option<&Bound<'_, PyDict>>, op: u32) -> PyResult<(f32, f32)> { + Ok(match op { + MAP_AFFINE => ( + kw_f32(kwargs, "scale", 1.0)?, + kw_f32(kwargs, "offset", 0.0)?, + ), + MAP_CLAMP => (kw_f32(kwargs, "lo", 0.0)?, kw_f32(kwargs, "hi", 1.0)?), + MAP_GREATER | MAP_LESS | MAP_GEQ | MAP_LEQ | MAP_EQ | MAP_NEQ => ( + kw_f32(kwargs, "threshold", 0.0)?, + kw_f32(kwargs, "epsilon", 1.0e-6)?, + ), + _ => (0.0, 0.0), + }) +} + +fn map_param_keys(op: u32) -> &'static [&'static str] { + match op { + MAP_AFFINE => &["scale", "offset"], + MAP_CLAMP => &["lo", "hi"], + MAP_GREATER | MAP_LESS | MAP_GEQ | MAP_LEQ | MAP_EQ | MAP_NEQ => &["threshold", "epsilon"], + _ => &[], + } +} + +fn reject_unknown_kwargs(kwargs: Option<&Bound<'_, PyDict>>, valid: &[&str]) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return Ok(()); + }; + for key in kwargs.keys() { + let name: String = key.extract()?; + if !valid.iter().any(|v| *v == name) { + return Err(PyValueError::new_err(format!( + "apply(): unknown parameter {name:?} (valid: {})", + valid.join(", ") + ))); + } + } + Ok(()) +} + +fn kw_op( + kwargs: Option<&Bound<'_, PyDict>>, + default: u32, + parse: fn(&str) -> PyResult, +) -> PyResult { + match kw(kwargs, "op") { + Some(v) => parse(&v.extract::()?), + None => Ok(default), + } +} + +#[pyclass(eq, eq_int, from_py_object)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AttributeFormat { + Float = 1, + Float2 = 2, + Float3 = 3, + Float4 = 4, +} + +impl AttributeFormat { + pub(crate) fn to_inner(self) -> geometry::AttributeFormat { + match self { + Self::Float => geometry::AttributeFormat::Float, + Self::Float2 => geometry::AttributeFormat::Float2, + Self::Float3 => geometry::AttributeFormat::Float3, + Self::Float4 => geometry::AttributeFormat::Float4, + } + } + + pub(crate) fn from_inner(inner: geometry::AttributeFormat) -> Self { + match inner { + geometry::AttributeFormat::Float => Self::Float, + geometry::AttributeFormat::Float2 => Self::Float2, + geometry::AttributeFormat::Float3 => Self::Float3, + geometry::AttributeFormat::Float4 => Self::Float4, + } + } + + pub(crate) fn float_count(self) -> usize { + match self { + Self::Float => 1, + Self::Float2 => 2, + Self::Float3 => 3, + Self::Float4 => 4, + } + } +} + +#[pyclass(unsendable, frozen, hash, eq, from_py_object)] +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Attribute { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Attribute { + #[new] + pub fn new(name: &str, format: AttributeFormat) -> PyResult { + let entity = geometry_attribute_create(name, format.to_inner()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[staticmethod] + pub fn position() -> Self { + Self { + entity: geometry_attribute_position(), + } + } + #[staticmethod] + pub fn normal() -> Self { + Self { + entity: geometry_attribute_normal(), + } + } + #[staticmethod] + pub fn color() -> Self { + Self { + entity: geometry_attribute_color(), + } + } + #[staticmethod] + pub fn uv() -> Self { + Self { + entity: geometry_attribute_uv(), + } + } + #[staticmethod] + pub fn rotation() -> Self { + Self { + entity: geometry_attribute_rotation(), + } + } + #[staticmethod] + pub fn scale() -> Self { + Self { + entity: geometry_attribute_scale(), + } + } + #[staticmethod] + pub fn life() -> Self { + Self { + entity: geometry_attribute_life(), + } + } + #[staticmethod] + pub fn velocity() -> Self { + Self { + entity: geometry_attribute_velocity(), + } + } + #[staticmethod] + pub fn age() -> Self { + Self { + entity: geometry_attribute_age(), + } + } + + #[getter] + pub fn name(&self) -> PyResult { + let (name, _) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(name) + } + + #[getter] + pub fn format(&self) -> PyResult { + let (_, fmt) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(AttributeFormat::from_inner(fmt)) + } +} + +#[pyclass(unsendable)] +pub struct Particles { + pub(crate) entity: Entity, + name_to_attr: HashMap, +} + +impl Particles { + fn build_name_index( + attrs: &[Attribute], + ) -> PyResult> { + let mut map = HashMap::with_capacity(attrs.len()); + for attr in attrs { + let (name, fmt) = geometry_attribute_info(attr.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + map.insert(name, (attr.entity, AttributeFormat::from_inner(fmt))); + } + Ok(map) + } + + /// Resolve a built-in attribute name to its `Attribute`. + fn builtin_attribute(name: &str) -> Option { + Some(match name { + "position" => Attribute::position(), + "velocity" => Attribute::velocity(), + "normal" => Attribute::normal(), + "color" => Attribute::color(), + "uv" => Attribute::uv(), + "rotation" => Attribute::rotation(), + "scale" => Attribute::scale(), + "life" => Attribute::life(), + "age" => Attribute::age(), + _ => return None, + }) + } + + /// Resolve a `buffer()` argument (an attribute name string or an `Attribute`) + /// to its attribute entity. Built-in names map to their factory; other names + /// must have been declared as custom attributes at construction. + fn resolve_attribute(&self, attribute: &Bound<'_, PyAny>) -> PyResult { + if let Ok(attr) = attribute.extract::() { + return Ok(attr.entity); + } + if let Ok(name) = attribute.extract::() { + if let Some(attr) = Self::builtin_attribute(&name) { + return Ok(attr.entity); + } + if let Some((entity, _)) = self.name_to_attr.get(&name) { + return Ok(*entity); + } + return Err(PyValueError::new_err(format!( + "\"{name}\" is not a built-in attribute; pass its Attribute to buffer()" + ))); + } + Err(PyTypeError::new_err( + "buffer() expects an attribute name or an Attribute", + )) + } + + fn resolve_operand(&self, val: &Bound<'_, PyAny>) -> PyResult<(Entity, u32)> { + if let Ok(b) = val.extract::>() { + let comp = b.components().ok_or_else(|| { + PyRuntimeError::new_err( + "operand buffer has no element type; pass a particle attribute \ + or a buffer created with typed data", + ) + })?; + return Ok((b.entity, comp)); + } + let attr_entity = self.resolve_attribute(val)?; + let buf = particles_ensure_attribute(self.entity, attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (_, fmt) = geometry_attribute_info(attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let comp = match AttributeFormat::from_inner(fmt) { + AttributeFormat::Float => 1, + AttributeFormat::Float2 => 2, + AttributeFormat::Float3 => 3, + AttributeFormat::Float4 => 4, + }; + Ok((buf, comp)) + } + + fn operand(&self, kwargs: Option<&Bound<'_, PyDict>>, key: &str) -> PyResult<(Entity, u32)> { + let val = kw(kwargs, key) + .ok_or_else(|| PyRuntimeError::new_err(format!("apply(): missing operand '{key}'")))?; + self.resolve_operand(&val) + } + + fn dest(&self, kwargs: Option<&Bound<'_, PyDict>>, in_place: Entity) -> PyResult { + match kw(kwargs, "out") { + Some(v) => Ok(self.resolve_operand(&v)?.0), + None => Ok(in_place), + } + } + + /// Build a particle system (backs `create_particles`). Attributes default to + /// `position`; the rest (built-in or declared custom) materialize on demand. + pub(crate) fn create( + capacity: Option, + attributes: Option>>, + geometry: Option<&Geometry>, + ) -> PyResult { + let attrs: Vec = match attributes { + Some(list) => list.iter().map(|a| (**a).clone()).collect(), + None => vec![Attribute::position()], + }; + let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); + + let entity = match (capacity, geometry) { + (Some(cap), None) => particles_create(cap, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, Some(g)) => particles_create_from_geometry(g.entity, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, None) => { + return Err(PyRuntimeError::new_err( + "create_particles() requires either capacity or geometry", + )); + } + (Some(_), Some(_)) => { + return Err(PyRuntimeError::new_err( + "create_particles() accepts capacity or geometry, not both", + )); + } + }; + + Ok(Self { + entity, + name_to_attr: Particles::build_name_index(&attrs)?, + }) + } + + fn apply_named(&self, name: &str, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + fn rt(e: impl std::fmt::Display) -> PyErr { + PyRuntimeError::new_err(format!("{e}")) + } + + if let Some(entity) = physics_compute(name)? { + if let Some(kwargs) = kwargs { + crate::compute::set_compute_kwargs(entity, kwargs)?; + } + return particles_apply(self.entity, entity).map_err(rt); + } + + if name.eq_ignore_ascii_case(c::MAP) { + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.dest(kwargs, a)?; + let op = kw_op(kwargs, MAP_AFFINE, parse_map_op)?; + let mut valid = vec!["a", "out", "op"]; + valid.extend_from_slice(map_param_keys(op)); + reject_unknown_kwargs(kwargs, &valid)?; + let (p0, p1) = map_params(kwargs, op)?; + algebra_map(out, a, comp, op, p0, p1).map_err(rt) + } else if name.eq_ignore_ascii_case(c::COMBINE) { + reject_unknown_kwargs(kwargs, &["a", "b", "out", "op", "b_scale", "b_offset"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let (b, b_comp) = self.operand(kwargs, "b")?; + if b_comp != comp { + return Err(PyValueError::new_err(format!( + "apply(combine): `a` has {comp} components but `b` has {b_comp} (must match)" + ))); + } + let out = self.dest(kwargs, a)?; + let op = kw_op(kwargs, COMBINE_ADD, parse_combine_op)?; + let b_scale = kw_f32(kwargs, "b_scale", 1.0)?; + let b_offset = kw_f32(kwargs, "b_offset", 0.0)?; + algebra_combine(out, a, b, comp, op, b_scale, b_offset).map_err(rt) + } else if name.eq_ignore_ascii_case(c::MIX) { + reject_unknown_kwargs( + kwargs, + &["a", "b", "t", "out", "t_scale", "t_offset", "t_clamp"], + )?; + let (a, comp) = self.operand(kwargs, "a")?; + let (b, b_comp) = self.operand(kwargs, "b")?; + let (t, t_comp) = self.operand(kwargs, "t")?; + if b_comp != comp { + return Err(PyValueError::new_err(format!( + "apply(mix): `a` has {comp} components but `b` has {b_comp} (must match)" + ))); + } + if t_comp != 1 { + return Err(PyValueError::new_err(format!( + "apply(mix): `t` must be a per-particle scalar (1 component), got {t_comp}" + ))); + } + let out = self.dest(kwargs, a)?; + let t_scale = kw_f32(kwargs, "t_scale", 1.0)?; + let t_offset = kw_f32(kwargs, "t_offset", 0.0)?; + let t_clamp = kw_bool(kwargs, "t_clamp", true)?; + algebra_mix(out, a, b, t, comp, t_scale, t_offset, t_clamp).map_err(rt) + } else if name.eq_ignore_ascii_case(c::LOOKUP) { + reject_unknown_kwargs( + kwargs, + &[ + "a", + "out", + "tex", + "u_scale", + "u_offset", + "v_scale", + "v_offset", + "color_scale", + ], + )?; + let (a, in_comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let tex = kw(kwargs, "tex") + .ok_or_else(|| PyRuntimeError::new_err("apply(lookup): missing 'tex' Image"))? + .extract::>() + .map_err(|_| PyRuntimeError::new_err("apply(lookup): 'tex' must be an Image"))? + .entity; + let u_scale = kw_f32(kwargs, "u_scale", 1.0)?; + let u_offset = kw_f32(kwargs, "u_offset", 0.0)?; + let v_scale = kw_f32(kwargs, "v_scale", 1.0)?; + let v_offset = kw_f32(kwargs, "v_offset", 0.0)?; + let color_scale = kw_f32(kwargs, "color_scale", 1.0)?; + algebra_lookup( + out, + a, + tex, + in_comp, + u_scale, + u_offset, + v_scale, + v_offset, + color_scale, + ) + .map_err(rt) + } else if name.eq_ignore_ascii_case(c::REDUCE) { + reject_unknown_kwargs(kwargs, &["a", "out", "op"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let op = kw_op(kwargs, REDUCE_LENGTH, parse_reduce_op)?; + algebra_reduce(out, a, comp, op).map_err(rt) + } else if name.eq_ignore_ascii_case(c::EXTRACT) { + reject_unknown_kwargs(kwargs, &["a", "out", "index"])?; + let (a, comp) = self.operand(kwargs, "a")?; + let out = self.operand(kwargs, "out")?.0; + let index = kw_u32(kwargs, "index", 0)?; + algebra_extract(out, a, comp, index).map_err(rt) + } else if name.eq_ignore_ascii_case(c::PACK) { + reject_unknown_kwargs(kwargs, &["out", "sources"])?; + let out = self.operand(kwargs, "out")?.0; + let sources = kw(kwargs, "sources") + .ok_or_else(|| PyRuntimeError::new_err("apply(pack): missing 'sources' list"))?; + let items: Vec> = sources.extract()?; + let mut entities = Vec::with_capacity(items.len()); + for item in &items { + entities.push(self.resolve_operand(item)?.0); + } + algebra_pack(out, &entities).map_err(rt) + } else if name.eq_ignore_ascii_case(c::GENERATE) { + reject_unknown_kwargs(kwargs, &["out", "mode", "seed", "scale", "offset"])?; + let (out, comp) = self.operand(kwargs, "out")?; + let mode = match kw(kwargs, "mode") { + Some(v) => parse_generate_mode(&v.extract::()?)?, + None => GEN_UNIFORM, + }; + let seed = kw_u32(kwargs, "seed", 0)?; + let scale = kw_f32(kwargs, "scale", 1.0)?; + let offset = kw_f32(kwargs, "offset", 0.0)?; + algebra_generate(out, comp, mode, seed, scale, offset).map_err(rt) + } else if name.eq_ignore_ascii_case(c::NEIGHBOR) { + reject_unknown_kwargs(kwargs, &["a", "out", "grid", "op", "radius", "falloff"])?; + let grid = kw(kwargs, "grid") + .ok_or_else(|| PyRuntimeError::new_err("apply(neighbor): missing 'grid'"))? + .extract::>() + .map_err(|_| PyRuntimeError::new_err("apply(neighbor): 'grid' must be a Grid"))?; + let op = kw_op(kwargs, NEIGHBOR_MEAN, parse_neighbor_op)?; + let falloff = match kw(kwargs, "falloff") { + Some(v) => parse_falloff(&v.extract::()?)?, + None => FALLOFF_SMOOTHSTEP, + }; + let cell = grid.inner.params.cell_size; + let radius = kw_f32(kwargs, "radius", cell)?.min(cell); + + let (out, out_comp) = self.operand(kwargs, "out")?; + let (a, components) = if op == NEIGHBOR_COUNT { + if out_comp != 1 { + return Err(PyValueError::new_err( + "apply(neighbor, op=count/density): `out` must be a scalar (1 component)", + )); + } + let a = match kw(kwargs, "a") { + Some(_) => self.operand(kwargs, "a")?.0, + None => { + let pos = Self::builtin_attribute("position") + .expect("position is a built-in") + .entity; + particles_ensure_attribute(self.entity, pos).map_err(rt)? + } + }; + (a, 1u32) + } else { + let (a, in_comp) = self.operand(kwargs, "a")?; + if in_comp != out_comp { + return Err(PyValueError::new_err(format!( + "apply(neighbor): source has {in_comp} components but out has {out_comp}" + ))); + } + (a, in_comp) + }; + particles_gather( + self.entity, + &grid.inner, + a, + out, + op, + radius, + falloff, + components, + ) + .map_err(rt) + } else { + Err(PyValueError::new_err(format!( + "apply(): unknown operation {name:?}" + ))) + } + } +} + +#[pymethods] +impl Particles { + #[getter] + pub fn capacity(&self) -> PyResult { + particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (attribute, default=None))] + pub fn add_attribute( + &mut self, + attribute: PyRef, + default: Option<&Bound<'_, PyAny>>, + ) -> PyResult<()> { + let default_value = default + .map(crate::material::py_to_shader_value) + .transpose()?; + particles_attribute_add(self.entity, attribute.entity, default_value) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (name, fmt) = geometry_attribute_info(attribute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + self.name_to_attr + .insert(name, (attribute.entity, AttributeFormat::from_inner(fmt))); + Ok(()) + } + + /// The GPU buffer for an attribute, materialized on demand. `attribute` is a + /// built-in name (`"position"`, `"velocity"`, `"color"`, `"scale"`, `"life"`, + /// `"age"`, `"normal"`, `"uv"`, `"rotation"`), a declared custom attribute's + /// name, or an `Attribute`. + pub fn buffer(&self, attribute: &Bound<'_, PyAny>) -> PyResult { + let attr_entity = self.resolve_attribute(attribute)?; + let buf = particles_ensure_attribute(self.entity, attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (_, fmt) = geometry_attribute_info(attr_entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let element_type = match AttributeFormat::from_inner(fmt) { + AttributeFormat::Float => shader_value::ShaderValue::Float(0.0), + AttributeFormat::Float2 => shader_value::ShaderValue::Float2([0.0; 2]), + AttributeFormat::Float3 => shader_value::ShaderValue::Float3([0.0; 3]), + AttributeFormat::Float4 => shader_value::ShaderValue::Float4([0.0; 4]), + }; + Ok(Buffer::from_entity(buf, Some(element_type))) + } + + pub fn index_buffer(&self, index_count: u32) -> PyResult { + let entity = particles_set_connectivity(self.entity, index_count) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Buffer::from_entity( + entity, + Some(shader_value::ShaderValue::UInt(0)), + )) + } + + pub fn draw_args(&self) -> PyResult { + let entity = particles_connectivity_indirect(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Buffer::from_entity( + entity, + Some(shader_value::ShaderValue::UInt(0)), + )) + } + + pub fn reset_indices(&self) -> PyResult<()> { + particles_reset_indices(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(signature = (kind, **kwargs))] + pub fn apply( + &self, + kind: &Bound<'_, PyAny>, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + if let Ok(compute) = kind.extract::>() { + if let Some(kwargs) = kwargs { + compute.set(Some(kwargs))?; + } + return particles_apply(self.entity, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + let name: String = kind.extract().map_err(|_| { + PyTypeError::new_err( + "apply(): first argument must be an operation constant or a Compute", + ) + })?; + self.apply_named(&name, kwargs) + } + + #[pyo3(signature = (n, **kwargs))] + pub fn emit(&self, n: u32, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return particles_emit(self.entity, n, vec![]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + }; + let mut data: Vec<(Entity, Vec)> = Vec::new(); + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let (attr_entity, fmt) = self.name_to_attr.get(&name).copied().ok_or_else(|| { + PyRuntimeError::new_err(format!( + "no attribute named '{name}' (registered: {:?})", + self.name_to_attr.keys().collect::>() + )) + })?; + let floats: Vec = value.extract()?; + let expected = (n as usize) * fmt.float_count(); + if floats.len() != expected { + return Err(PyRuntimeError::new_err(format!( + "attribute '{name}': expected {expected} floats ({} per particle × {n}), got {}", + fmt.float_count(), + floats.len(), + ))); + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + data.push((attr_entity, bytes)); + } + particles_emit(self.entity, n, data).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn emit_gpu(&self, n: u32, compute: &Compute) -> PyResult<()> { + particles_emit_gpu(self.entity, n, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn compact(&self, flags: &Bound<'_, PyAny>, out: &Buffer) -> PyResult { + let (flag_buf, _) = self.resolve_operand(flags)?; + compact_indices(flag_buf, out.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[staticmethod] + pub fn noise() -> PyResult { + let entity = + particles_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn transform() -> PyResult { + let entity = + particles_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn attract() -> PyResult { + let entity = + particles_kernel_attract().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn drag() -> PyResult { + let entity = + particles_kernel_drag().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn vortex() -> PyResult { + let entity = + particles_kernel_vortex().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn force() -> PyResult { + let entity = + particles_kernel_force().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn integrate() -> PyResult { + let entity = + particles_kernel_integrate().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn age() -> PyResult { + let entity = particles_kernel_age().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn bounds_sphere() -> PyResult { + let entity = particles_kernel_bounds_sphere() + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn bounds_box() -> PyResult { + let entity = + particles_kernel_bounds_box().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn bounds_geometry(geometry: &Geometry) -> PyResult { + let entity = particles_kernel_bounds_geometry(geometry.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn impulse() -> PyResult { + let entity = + particles_kernel_impulse().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + pub fn create_grid(&self, min: [f32; 3], cell_size: f32, dims: [u32; 3]) -> PyResult { + let capacity = + particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let params = GridParams { + min, + cell_size, + dims, + }; + let inner = + grid_create(params, capacity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Grid { inner }) + } + + #[pyo3(signature = (grid, **kwargs))] + pub fn flock(&self, grid: &Grid, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let flock = flock_compute()?; + if let Some(kwargs) = kwargs { + crate::compute::set_compute_kwargs(flock, kwargs)?; + } + let cell = grid.inner.params.cell_size; + let neighbor_distance = kw_f32(kwargs, "neighbor_distance", cell)?.min(cell); + compute_set( + flock, + "neighbor_distance", + shader_value::ShaderValue::Float(neighbor_distance), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + particles_flock(self.entity, flock, &grid.inner) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[staticmethod] + pub fn orient() -> PyResult { + let entity = + particles_kernel_orient().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn field() -> PyResult { + let entity = + particles_kernel_field().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn scatter_surface(geometry: &Geometry) -> PyResult { + let entity = particles_scatter_create(geometry.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } + + #[staticmethod] + pub fn scatter_volume(geometry: &Geometry) -> PyResult { + let entity = particles_scatter_volume_create(geometry.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) + } +} + +impl Drop for Particles { + fn drop(&mut self) { + let _ = particles_destroy(self.entity); + } +} diff --git a/crates/processing_pyo3/src/shader.rs b/crates/processing_pyo3/src/shader.rs index 8960575b..42bef234 100644 --- a/crates/processing_pyo3/src/shader.rs +++ b/crates/processing_pyo3/src/shader.rs @@ -7,16 +7,13 @@ pub struct Shader { pub(crate) entity: Entity, } -#[pymethods] impl Shader { - #[new] - pub fn new(source: &str) -> PyResult { + pub(crate) fn from_source(source: &str) -> PyResult { let entity = shader_create(source).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } - #[staticmethod] - pub fn load(path: &str) -> PyResult { + pub(crate) fn from_path(path: &str) -> PyResult { let entity = shader_load(path).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; Ok(Self { entity }) } diff --git a/crates/processing_pyo3/src/surface.rs b/crates/processing_pyo3/src/surface.rs index 81f00932..906ccda1 100644 --- a/crates/processing_pyo3/src/surface.rs +++ b/crates/processing_pyo3/src/surface.rs @@ -13,6 +13,27 @@ pub struct Surface { pub(crate) glfw_ctx: Option, } +impl Surface { + /// Add a window on the shared GLFW instance (valid only on the main surface, + /// which owns the `GlfwContext`). Returns the new window's surface entity. + pub(crate) fn add_window( + &mut self, + width: u32, + height: u32, + transparent: bool, + title: &str, + ) -> PyResult { + match &mut self.glfw_ctx { + Some(ctx) => ctx + .add_window(width, height, transparent, title) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))), + None => Err(PyRuntimeError::new_err( + "create_window() requires a windowed sketch (call size() first)", + )), + } + } +} + #[pymethods] impl Surface { pub fn poll_events(&mut self) -> bool { diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index a2097a34..146a9bbe 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "processing_render" -version = "0.0.1" +version = "0.0.5" edition = "2024" [lints] @@ -14,18 +14,27 @@ x11 = ["bevy/x11"] [dependencies] bevy = { workspace = true } bevy_naga_reflect = { workspace = true } +# direct wgpu dep to reach `wgpu::util::TextureBlitter` (unifies to bevy's wgpu) +wgpu = "29" naga = { workspace = true } wesl = { workspace = true } lyon = "1.0" +parley = { version = "0.7", features = ["system"] } +# dlopen fontconfig at runtime instead of linking it, so Linux wheels stay manylinux-compliant +fontique = { version = "0.7", features = ["fontconfig-dlopen"] } +skrifa = "0.37" +notosans = "0.1" raw-window-handle = "0.6" half = "2.7" crossbeam-channel = "0.5" processing_core = { workspace = true } -processing_midi = { workspace = true } [build-dependencies] wesl = { workspace = true, features = ["package"] } +[target.'cfg(not(target_arch = "wasm32"))'.dependencies] +bevy = { workspace = true, features = ["file_watcher"] } + [target.'cfg(target_os = "macos")'.dependencies] objc2 = { version = "0.6", default-features = false } objc2-app-kit = { version = "0.3", features = ["NSWindow", "NSView"] } diff --git a/crates/processing_render/shaders/processing/filter.wesl b/crates/processing_render/shaders/processing/filter.wesl new file mode 100644 index 00000000..61a9bc50 --- /dev/null +++ b/crates/processing_render/shaders/processing/filter.wesl @@ -0,0 +1,32 @@ +struct ProcessingFilter { + resolution: vec2, + // 1.0 / resolution — one-texel step for neighbor sampling. + texel_size: vec2, + // Pass index (0-based) and total count. + pass_index: u32, + pass_count: u32, +} + +@group(0) @binding(0) var screen_texture: texture_2d; +@group(0) @binding(1) var texture_sampler: sampler; +@group(0) @binding(2) var params: ProcessingFilter; + +struct FullscreenVertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, +} + +fn sample(uv: vec2) -> vec4 { + return textureSample(screen_texture, texture_sampler, uv); +} + +// Sample an arbitrary input texture (e.g. a composite's source operand) with the +// shared engine sampler, so composite/multi-input filters need not bind their own. +fn sample_texture(tex: texture_2d, uv: vec2) -> vec4 { + return textureSample(tex, texture_sampler, uv); +} + +fn resolution() -> vec2 { return params.resolution; } +fn texel_size() -> vec2 { return params.texel_size; } +fn pass_index() -> u32 { return params.pass_index; } +fn pass_count() -> u32 { return params.pass_count; } diff --git a/crates/processing_render/shaders/processing/particles.wesl b/crates/processing_render/shaders/processing/particles.wesl new file mode 100644 index 00000000..9545b2b3 --- /dev/null +++ b/crates/processing_render/shaders/processing/particles.wesl @@ -0,0 +1,28 @@ +fn falloff(d: f32, radius: f32, mode: u32) -> f32 { + let n = 1.0 - d / radius; + switch mode { + case 1u: { return n; } + case 2u: { return n * n * (3.0 - 2.0 * n); } + case 3u: { return n * n; } + case 4u: { return n * n * n; } + case 5u: { return radius / (d + radius); } + default: { return 1.0; } + } +} + +fn cell_coords(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> vec3 { + let rel = (p - grid_min) / cell_size; + return vec3( + clamp(i32(floor(rel.x)), 0, i32(dims.x) - 1), + clamp(i32(floor(rel.y)), 0, i32(dims.y) - 1), + clamp(i32(floor(rel.z)), 0, i32(dims.z) - 1), + ); +} + +fn cell_index(c: vec3, dims: vec3) -> u32 { + return c.x + c.y * dims.x + c.z * dims.x * dims.y; +} + +fn cell_of(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> u32 { + return cell_index(vec3(cell_coords(p, grid_min, cell_size, dims)), dims); +} diff --git a/crates/processing_render/src/color.rs b/crates/processing_render/src/color.rs index 932fc8d1..4883c6db 100644 --- a/crates/processing_render/src/color.rs +++ b/crates/processing_render/src/color.rs @@ -33,6 +33,23 @@ impl ColorSpace { } } + pub fn parse(s: &str) -> Option { + use processing_core::constants as consts; + match () { + _ if s.eq_ignore_ascii_case(consts::SRGB) => Some(Self::Srgb), + _ if s.eq_ignore_ascii_case(consts::LINEAR) => Some(Self::Linear), + _ if s.eq_ignore_ascii_case(consts::HSL) => Some(Self::Hsl), + _ if s.eq_ignore_ascii_case(consts::HSV) => Some(Self::Hsv), + _ if s.eq_ignore_ascii_case(consts::HWB) => Some(Self::Hwb), + _ if s.eq_ignore_ascii_case(consts::OKLAB) => Some(Self::Oklab), + _ if s.eq_ignore_ascii_case(consts::OKLCH) => Some(Self::Oklch), + _ if s.eq_ignore_ascii_case(consts::LAB) => Some(Self::Lab), + _ if s.eq_ignore_ascii_case(consts::LCH) => Some(Self::Lch), + _ if s.eq_ignore_ascii_case(consts::XYZ) => Some(Self::Xyz), + _ => None, + } + } + pub fn default_maxes(&self) -> [f32; 4] { match self { Self::Srgb | Self::Linear | Self::Oklab | Self::Xyz => [1.0, 1.0, 1.0, 1.0], diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs new file mode 100644 index 00000000..4b56fe6a --- /dev/null +++ b/crates/processing_render/src/compute.rs @@ -0,0 +1,474 @@ +use std::collections::{BTreeSet, HashMap}; + +use bevy::asset::{AssetId, RenderAssetUsages}; +use bevy::mesh::MeshVertexAttribute; +use bevy::{ + prelude::*, + render::{ + RenderApp, + mesh::{RenderMesh, allocator::MeshAllocator}, + render_asset::RenderAssets, + render_resource::{ + BindGroupLayoutDescriptor, Buffer as WgpuBuffer, BufferDescriptor, BufferUsages, + CachedComputePipelineId, CachedPipelineState, CommandEncoderDescriptor, + ComputePassDescriptor, ComputePipelineDescriptor, MapMode, OwnedBindingResource, + PipelineCache, PollType, + }, + renderer::{RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + texture::GpuImage, + }, +}; + +use bevy_naga_reflect::dynamic_shader::DynamicShader; + +use crate::geometry::{Attribute, Geometry}; +use crate::material::custom::Shader; +use processing_core::error::{ProcessingError, Result}; + +pub struct ComputePlugin; + +impl Plugin for ComputePlugin { + fn build(&self, app: &mut App) { + app.add_systems(Last, invalidate_rw_buffers); + } +} + +#[derive(Component)] +pub struct Buffer { + pub handle: Handle, + pub readback_buffer: WgpuBuffer, + pub size: u64, + pub synced: bool, + pub bound_rw: bool, +} + +fn readback_buffer(device: &RenderDevice, size: u64) -> WgpuBuffer { + device.create_buffer(&BufferDescriptor { + label: Some("Buffer Readback"), + size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }) +} + +pub fn create_buffer( + In(size): In, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let handle = buffers.add(ShaderBuffer::new( + &vec![0u8; size as usize], + RenderAssetUsages::all(), + )); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + +pub fn create_buffer_with_usage( + In((size, extra_usage)): In<(u64, BufferUsages)>, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let mut shader_buffer = ShaderBuffer::new(&vec![0u8; size as usize], RenderAssetUsages::all()); + shader_buffer.buffer_description.usage |= extra_usage; + let handle = buffers.add(shader_buffer); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + +pub fn create_buffer_with_data( + In(data): In>, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let size = data.len() as u64; + let handle = buffers.add(ShaderBuffer::new(&data, RenderAssetUsages::all())); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + +pub fn write_buffer_cpu( + In((handle, offset, data)): In<(Handle, u64, Vec)>, + mut buffers: ResMut>, +) -> Result<()> { + let mut asset = buffers + .get_mut(&handle) + .ok_or(ProcessingError::BufferNotFound)?; + let dst = asset.data.as_mut().ok_or(ProcessingError::BufferNotFound)?; + let start = offset as usize; + let end = start + data.len(); + dst[start..end].copy_from_slice(&data); + Ok(()) +} + +/// caller must write bytes back via `get_mut_untracked` to avoid triggering +/// a re-upload. +pub fn read_buffer_gpu( + In((handle, readback_buffer, size)): In<(Handle, WgpuBuffer, u64)>, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, +) -> Result> { + let gpu_buffer = &gpu_buffers + .get(&handle) + .ok_or(ProcessingError::BufferNotFound)? + .buffer; + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + encoder.copy_buffer_to_buffer(gpu_buffer, 0, &readback_buffer, 0, size); + render_queue.submit(std::iter::once(encoder.finish())); + + let buffer_slice = readback_buffer.slice(0..size); + let (s, r) = crossbeam_channel::bounded(1); + buffer_slice.map_async(MapMode::Read, move |result| { + let _ = s.send(result); + }); + render_device + .poll(PollType::wait_indefinitely()) + .map_err(|e| ProcessingError::BufferMapError(format!("poll failed: {e}")))?; + r.recv() + .map_err(|e| ProcessingError::BufferMapError(format!("map channel closed: {e}")))? + .map_err(|e| ProcessingError::BufferMapError(format!("map failed: {e}")))?; + + let bytes = buffer_slice.get_mapped_range().to_vec(); + readback_buffer.unmap(); + Ok(bytes) +} + +pub fn invalidate_rw_buffers(mut buffers: Query<&mut Buffer>) { + for mut buf in &mut buffers { + if buf.bound_rw && buf.synced { + buf.synced = false; + } + } +} + +pub fn destroy_buffer(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} + +#[derive(Clone, Copy)] +pub enum MeshBindingRef { + Attribute { geom: Entity, attribute: Entity }, + Index { geom: Entity }, +} + +#[derive(Clone)] +pub enum ResolvedMeshBinding { + Attribute { + mesh_id: AssetId, + attribute: MeshVertexAttribute, + }, + Index { + mesh_id: AssetId, + }, +} + +#[derive(Component)] +pub struct Compute { + pub shader: DynamicShader, + pub entry_point: String, + pub pipeline_id: CachedComputePipelineId, + pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, + pub mesh_bindings: HashMap, +} + +fn queue_pipeline( + In(descriptor): In, + pipeline_cache: Res, +) -> CachedComputePipelineId { + pipeline_cache.queue_compute_pipeline(descriptor) +} + +fn pump_pipeline( + In(id): In, + mut pipeline_cache: ResMut, +) -> Result { + pipeline_cache.process_queue(); + match pipeline_cache.get_compute_pipeline_state(id) { + CachedPipelineState::Ok(_) => Ok(true), + CachedPipelineState::Err(e) => Err(ProcessingError::PipelineCompileError(format!("{e}"))), + _ => Ok(false), + } +} + +pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { + let (module, shader_handle) = { + let program = app + .world() + .get::(shader_entity) + .ok_or(ProcessingError::ShaderNotFound)?; + (program.module.clone(), program.shader_handle.clone()) + }; + + let compute_ep = module + .entry_points + .iter() + .find(|ep| ep.stage == naga::ShaderStage::Compute) + .ok_or_else(|| { + ProcessingError::ShaderCompilationError( + "Shader has no @compute entry point".to_string(), + ) + })?; + let entry_point = compute_ep.name.clone(); + + let mut shader = DynamicShader::new(module).map_err(|e| { + let mut msg = e.to_string(); + let mut src: &dyn std::error::Error = &e; + while let Some(s) = src.source() { + msg.push_str(&format!("\n caused by: {s}")); + src = s; + } + ProcessingError::ShaderCompilationError(msg) + })?; + shader.init(); + + let reflection = shader.reflection(); + let groups: BTreeSet = reflection.parameters().map(|p| p.group()).collect(); + + let bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)> = groups + .iter() + .map(|&group| { + let entries = reflection.bind_group_layout(group); + ( + group, + BindGroupLayoutDescriptor { + label: "compute_bind_group_layout".into(), + entries, + }, + ) + }) + .collect(); + + let max_group = groups.iter().last().copied().map_or(0, |g| g + 1); + let mut layout_descriptors = vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + for (group, desc) in &bind_group_layout_descriptors { + layout_descriptors[*group as usize] = desc.clone(); + } + + let descriptor = ComputePipelineDescriptor { + label: Some("processing_compute".into()), + layout: layout_descriptors, + immediate_size: 0, + shader: shader_handle.clone(), + shader_defs: Vec::new(), + entry_point: Some(entry_point.clone().into()), + zero_initialize_workgroup_memory: true, + }; + + let pipeline_id = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(queue_pipeline, descriptor) + .unwrap(); + + const MAX_WAIT: u32 = 64; + for _ in 0..MAX_WAIT { + app.update(); + let done = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(pump_pipeline, pipeline_id) + .unwrap()?; + if done { + return Ok(app + .world_mut() + .spawn(Compute { + shader, + entry_point, + pipeline_id, + bind_group_layout_descriptors, + mesh_bindings: HashMap::new(), + }) + .id()); + } + } + Err(ProcessingError::PipelineNotReady(MAX_WAIT)) +} + +pub fn dispatch( + In((pipeline_id, layout_descriptors, shader, mesh_bindings, x, y, z)): In<( + CachedComputePipelineId, + Vec<(u32, BindGroupLayoutDescriptor)>, + DynamicShader, + Vec<(String, ResolvedMeshBinding)>, + u32, + u32, + u32, + )>, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + gpu_buffers: Res>, + render_meshes: Res>, + mesh_allocator: Res, +) -> Result<()> { + let pipeline = pipeline_cache + .get_compute_pipeline(pipeline_id) + .ok_or(ProcessingError::PipelineNotReady(0))? + .clone(); + + let reflection = shader.reflection(); + + let mut bind_groups = Vec::new(); + for (group, desc) in &layout_descriptors { + let layout = pipeline_cache.get_bind_group_layout(desc); + let mut bindings = + reflection.create_bindings(*group, &shader, &render_device, &gpu_images, &gpu_buffers); + + for (name, resolved) in &mesh_bindings { + let Some(param) = reflection.parameter(name) else { + return Err(ProcessingError::UnknownShaderProperty(name.clone())); + }; + if param.group() != *group { + continue; + } + let buffer = resolve_mesh_binding(resolved, &render_meshes, &mesh_allocator)?; + let binding_idx = param.binding(); + if let Some(slot) = bindings.iter_mut().find(|(b, _)| *b == binding_idx) { + slot.1 = OwnedBindingResource::Buffer(buffer); + } else { + bindings.push((binding_idx, OwnedBindingResource::Buffer(buffer))); + } + } + + let bind_group_entries: Vec<_> = bindings + .iter() + .map( + |(binding, resource)| bevy::render::render_resource::BindGroupEntry { + binding: *binding, + resource: resource.get_binding(), + }, + ) + .collect(); + + let bind_group = render_device.create_bind_group( + Some("compute_bind_group"), + &layout, + &bind_group_entries, + ); + bind_groups.push(bind_group); + } + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + { + let mut pass = encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("compute_pass"), + ..Default::default() + }); + pass.set_pipeline(&pipeline); + for ((group, _), bg) in layout_descriptors.iter().zip(bind_groups.iter()) { + pass.set_bind_group(*group, bg, &[]); + } + pass.dispatch_workgroups(x, y, z); + } + render_queue.submit(std::iter::once(encoder.finish())); + + Ok(()) +} + +pub fn destroy_compute(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} + +fn resolve_mesh_binding( + resolved: &ResolvedMeshBinding, + render_meshes: &RenderAssets, + mesh_allocator: &MeshAllocator, +) -> Result { + match resolved { + ResolvedMeshBinding::Attribute { mesh_id, attribute } => { + let render_mesh = render_meshes + .get(*mesh_id) + .ok_or(ProcessingError::GeometryNotFound)?; + let binding_idx = render_mesh + .layout + .0 + .binding_index_for_attribute(attribute.id) + .ok_or_else(|| { + ProcessingError::InvalidArgument(format!( + "mesh has no `{}` attribute (deinterleave required?)", + attribute.name + )) + })?; + let slice = mesh_allocator + .mesh_vertex_slice(mesh_id, binding_idx as u8) + .ok_or_else(|| { + ProcessingError::InvalidArgument(format!( + "mesh attribute `{}` not yet allocated", + attribute.name + )) + })?; + Ok(slice.buffer.clone()) + } + ResolvedMeshBinding::Index { mesh_id } => { + let slice = mesh_allocator.mesh_index_slice(mesh_id).ok_or_else(|| { + ProcessingError::InvalidArgument( + "mesh has no index buffer or it is not yet allocated".to_string(), + ) + })?; + Ok(slice.buffer.clone()) + } + } +} + +pub fn resolve_mesh_bindings( + world: &World, + compute: &Compute, +) -> Result> { + let mut out = Vec::with_capacity(compute.mesh_bindings.len()); + for (name, mesh_ref) in &compute.mesh_bindings { + let resolved = match mesh_ref { + MeshBindingRef::Attribute { geom, attribute } => { + let g = world + .get::(*geom) + .ok_or(ProcessingError::GeometryNotFound)?; + let a = world + .get::(*attribute) + .ok_or(ProcessingError::InvalidEntity)?; + ResolvedMeshBinding::Attribute { + mesh_id: g.handle.id(), + attribute: a.inner, + } + } + MeshBindingRef::Index { geom } => { + let g = world + .get::(*geom) + .ok_or(ProcessingError::GeometryNotFound)?; + ResolvedMeshBinding::Index { + mesh_id: g.handle.id(), + } + } + }; + out.push((name.clone(), resolved)); + } + Ok(out) +} diff --git a/crates/processing_render/src/geometry/attribute.rs b/crates/processing_render/src/geometry/attribute.rs index ecef0b21..ca5f7764 100644 --- a/crates/processing_render/src/geometry/attribute.rs +++ b/crates/processing_render/src/geometry/attribute.rs @@ -148,6 +148,19 @@ impl AttributeFormat { } } + pub fn byte_size(self) -> usize { + match self { + Self::Float => 4, + Self::Float2 => 8, + Self::Float3 => 12, + Self::Float4 => 16, + } + } + + pub fn components(self) -> usize { + self.byte_size() / 4 + } + pub fn from_u8(value: u8) -> Option { match value { 1 => Some(Self::Float), @@ -168,9 +181,7 @@ pub struct Attribute { impl Attribute { pub fn new(name: impl Into, format: AttributeFormat) -> Self { - // we leak here to get a 'static str for the attribute name, but this is okay because - // we never expect to unload attributes during the lifetime of the application - // and attribute names are generally small in number + // leaked for a 'static name; attributes are never unloaded and are few. let name: &'static str = Box::leak(name.into().into_boxed_str()); let id = hash_attr_name(name); let inner = MeshVertexAttribute::new(name, id, format.to_vertex_format()); @@ -189,6 +200,18 @@ impl Attribute { } } + pub fn from_builtin_with_name( + name: &'static str, + inner: MeshVertexAttribute, + format: AttributeFormat, + ) -> Self { + Self { + name, + format, + inner, + } + } + pub fn id(&self) -> u64 { hash_attr_name(self.name) } @@ -200,50 +223,127 @@ pub struct BuiltinAttributes { pub normal: Entity, pub color: Entity, pub uv: Entity, + pub rotation: Entity, + pub scale: Entity, + pub life: Entity, + pub velocity: Entity, + pub age: Entity, } impl FromWorld for BuiltinAttributes { fn from_world(world: &mut World) -> Self { let position = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "position", Mesh::ATTRIBUTE_POSITION, AttributeFormat::Float3, )) .id(); let normal = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "normal", Mesh::ATTRIBUTE_NORMAL, AttributeFormat::Float3, )) .id(); let color = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "color", Mesh::ATTRIBUTE_COLOR, AttributeFormat::Float4, )) .id(); let uv = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "uv", Mesh::ATTRIBUTE_UV_0, AttributeFormat::Float2, )) .id(); + let rotation = world + .spawn(Attribute::new("rotation", AttributeFormat::Float4)) + .id(); + let scale = world + .spawn(Attribute::new("scale", AttributeFormat::Float3)) + .id(); + let life = world + .spawn(Attribute::new("life", AttributeFormat::Float)) + .id(); + let velocity = world + .spawn(Attribute::new("velocity", AttributeFormat::Float3)) + .id(); + let age = world + .spawn(Attribute::new("age", AttributeFormat::Float)) + .id(); Self { position, normal, color, uv, + rotation, + scale, + life, + velocity, + age, } } } +impl BuiltinAttributes { + pub fn by_name(&self, name: &str) -> Option { + Some(match name { + "position" => self.position, + "normal" => self.normal, + "color" => self.color, + "uv" => self.uv, + "rotation" => self.rotation, + "scale" => self.scale, + "life" => self.life, + "velocity" => self.velocity, + "age" => self.age, + _ => return None, + }) + } +} + +pub fn default_attribute_init(name: &str, format: AttributeFormat) -> Vec { + match name { + "life" => vec![1.0], + "scale" => vec![1.0, 1.0, 1.0], + "color" => vec![1.0, 1.0, 1.0, 1.0], + "rotation" => vec![0.0, 0.0, 0.0, 1.0], + _ => vec![0.0; format.components()], + } +} + +#[derive(Resource, Default)] +pub struct AttributeRegistry { + by_name: std::collections::HashMap, +} + pub fn create( In((name, format)): In<(String, AttributeFormat)>, mut commands: Commands, + builtins: Res, + mut registry: ResMut, ) -> Result { - // TODO: validation? - Ok(commands.spawn(Attribute::new(name, format)).id()) + if let Some(entity) = builtins.by_name(&name) { + return Ok(entity); + } + let id = hash_attr_name(&name); + if let Some(&(entity, existing)) = registry.by_name.get(&id) { + if existing != format { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{name}' was already declared as {existing:?}, cannot \ + redeclare it as {format:?}" + ))); + } + return Ok(entity); + } + let entity = commands.spawn(Attribute::new(name, format)).id(); + registry.by_name.insert(id, (entity, format)); + Ok(entity) } pub fn destroy(In(entity): In, mut commands: Commands) -> Result<()> { @@ -378,3 +478,45 @@ pub fn set_attribute( )), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn builtin_defaults_match_format() { + use AttributeFormat::*; + let builtins = [ + ("position", Float3), + ("normal", Float3), + ("color", Float4), + ("uv", Float2), + ("rotation", Float4), + ("scale", Float3), + ("life", Float), + ("velocity", Float3), + ("age", Float), + ]; + for (name, format) in builtins { + assert_eq!( + default_attribute_init(name, format).len(), + format.components(), + "default_attribute_init({name:?}) does not match {format:?} component count", + ); + } + } + + #[test] + fn unknown_name_seeds_zero_for_its_format() { + for format in [ + AttributeFormat::Float, + AttributeFormat::Float2, + AttributeFormat::Float3, + AttributeFormat::Float4, + ] { + let seed = default_attribute_init("custom_thing", format); + assert_eq!(seed.len(), format.components()); + assert!(seed.iter().all(|&f| f == 0.0)); + } + } +} diff --git a/crates/processing_render/src/geometry/mod.rs b/crates/processing_render/src/geometry/mod.rs index 4393bcdf..0ed13339 100644 --- a/crates/processing_render/src/geometry/mod.rs +++ b/crates/processing_render/src/geometry/mod.rs @@ -16,7 +16,7 @@ use bevy::{ render::render_resource::PrimitiveTopology, }; -use crate::render::primitive::{box_mesh, sphere_mesh}; +use crate::render::primitive::{box_mesh, grid_mesh, sphere_mesh}; use processing_core::error::{ProcessingError, Result}; pub struct GeometryPlugin; @@ -24,6 +24,7 @@ pub struct GeometryPlugin; impl Plugin for GeometryPlugin { fn build(&self, app: &mut App) { app.init_resource::(); + app.init_resource::(); } } @@ -59,6 +60,19 @@ impl Topology { _ => None, } } + + /// Accepts the shape names (`triangles`, `lines`, ...), not the variant names. + pub fn parse(s: &str) -> Option { + use processing_core::constants as consts; + match () { + _ if s.eq_ignore_ascii_case(consts::POINTS) => Some(Self::PointList), + _ if s.eq_ignore_ascii_case(consts::LINES) => Some(Self::LineList), + _ if s.eq_ignore_ascii_case(consts::LINE_STRIP) => Some(Self::LineStrip), + _ if s.eq_ignore_ascii_case(consts::TRIANGLES) => Some(Self::TriangleList), + _ if s.eq_ignore_ascii_case(consts::TRIANGLE_STRIP) => Some(Self::TriangleStrip), + _ => None, + } + } } #[derive(Component)] @@ -193,6 +207,45 @@ pub fn create_sphere( commands.spawn(Geometry::new(handle, layout_entity)).id() } +pub fn create_grid( + In((nx, ny, nz, spacing)): In<(u32, u32, u32, f32)>, + mut commands: Commands, + mut meshes: ResMut>, + builtins: Res, +) -> Entity { + let handle = meshes.add(grid_mesh(nx, ny, nz, spacing)); + + let layout_entity = commands + .spawn(VertexLayout::with_attributes(vec![ + builtins.position, + builtins.color, + builtins.uv, + ])) + .id(); + + commands.spawn(Geometry::new(handle, layout_entity)).id() +} + +pub fn create_from_mesh( + In(mesh): In, + mut commands: Commands, + mut meshes: ResMut>, + builtins: Res, +) -> Entity { + let handle = meshes.add(mesh); + + let layout_entity = commands + .spawn(VertexLayout::with_attributes(vec![ + builtins.position, + builtins.normal, + builtins.color, + builtins.uv, + ])) + .id(); + + commands.spawn(Geometry::new(handle, layout_entity)).id() +} + pub fn normal(world: &mut World, entity: Entity, normal: Vec3) -> Result<()> { let mut geometry = world .get_mut::(entity) diff --git a/crates/processing_render/src/gltf.rs b/crates/processing_render/src/gltf.rs index 84cd75f1..7b9397be 100644 --- a/crates/processing_render/src/gltf.rs +++ b/crates/processing_render/src/gltf.rs @@ -8,14 +8,16 @@ use bevy::{ }, camera::visibility::RenderLayers, ecs::system::RunSystemOnce, - gltf::{Gltf, GltfMeshName}, + gltf::{Gltf, GltfMaterial, GltfMeshName}, + pbr::ExtendedMaterial, prelude::*, world_serialization::WorldInstanceSpawner, }; use crate::geometry::{BuiltinAttributes, Geometry, layout::VertexLayout}; use crate::graphics; -use crate::render::material::UntypedMaterial; +use crate::material::ProcessingMaterial; +use crate::render::material::{ProcessingExtendedMaterial, UntypedMaterial}; use processing_core::config::{Config, ConfigKey}; use processing_core::error::{ProcessingError, Result}; @@ -67,7 +69,6 @@ pub struct GltfHandle { handle: Handle, instance_id: bevy::world_serialization::InstanceId, graphics_entity: Entity, - base_path: String, } pub fn load( @@ -76,10 +77,10 @@ pub fn load( ) -> Result { let config = world.resource::().clone(); let base_path = match path.find('#') { - Some(idx) => path[..idx].to_string(), - None => path.clone(), + Some(idx) => &path[..idx], + None => path.as_str(), }; - let asset_path = resolve_asset_path(&config, &base_path); + let asset_path = resolve_asset_path(&config, base_path); let handle: Handle = world.get_asset_server().load(asset_path); block_on_load(world, |w| w.get_asset_server().load_state(&handle))?; @@ -122,7 +123,6 @@ pub fn load( handle, instance_id, graphics_entity, - base_path, }) .id(); Ok(entity) @@ -182,40 +182,72 @@ pub fn geometry( Ok(entity) } +/// Translate a bevy [`GltfMaterial`] into the [`StandardMaterial`] base of a +/// [`ProcessingExtendedMaterial`]. `GltfMaterial` mirrors `StandardMaterial`'s +/// fields, so we copy across the ones that drive shading and let the rest fall +/// back to defaults. +fn gltf_material_to_standard(m: &GltfMaterial) -> StandardMaterial { + StandardMaterial { + base_color: m.base_color, + base_color_channel: m.base_color_channel.clone(), + base_color_texture: m.base_color_texture.clone(), + emissive: m.emissive, + emissive_channel: m.emissive_channel.clone(), + emissive_texture: m.emissive_texture.clone(), + perceptual_roughness: m.perceptual_roughness, + metallic: m.metallic, + metallic_roughness_channel: m.metallic_roughness_channel.clone(), + metallic_roughness_texture: m.metallic_roughness_texture.clone(), + normal_map_channel: m.normal_map_channel.clone(), + normal_map_texture: m.normal_map_texture.clone(), + occlusion_channel: m.occlusion_channel.clone(), + occlusion_texture: m.occlusion_texture.clone(), + double_sided: m.double_sided, + cull_mode: m.cull_mode, + unlit: m.unlit, + alpha_mode: m.alpha_mode, + uv_transform: m.uv_transform, + ..default() + } +} + pub fn material( In((gltf_entity, name)): In<(Entity, String)>, world: &mut World, ) -> Result { - let handle = world + let gltf_handle = world .get::(gltf_entity) - .ok_or(ProcessingError::InvalidEntity)?; - let gltf_handle = handle.handle.clone(); - let base_path = handle.base_path.clone(); + .ok_or(ProcessingError::InvalidEntity)? + .handle + .clone(); - let material_index = { + let standard = { let gltf_assets = world.resource::>(); let gltf = gltf_assets .get(&gltf_handle) .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; - let named_handle = gltf.named_materials.get(name.as_str()).ok_or_else(|| { + let mat_handle = gltf.named_materials.get(name.as_str()).ok_or_else(|| { ProcessingError::GltfLoadError(format!("Material '{}' not found in GLTF", name)) })?; - gltf.materials - .iter() - .position(|h| h.id() == named_handle.id()) - .ok_or_else(|| { - ProcessingError::GltfLoadError(format!( - "Material '{}' not found in materials list", - name - )) - })? + + let gltf_materials = world.resource::>(); + let gltf_material = gltf_materials.get(mat_handle).ok_or_else(|| { + ProcessingError::GltfLoadError(format!("Material '{}' asset not loaded", name)) + })?; + gltf_material_to_standard(gltf_material) }; - let config = world.resource::().clone(); - let std_path = format!("{}#Material{}/std", base_path, material_index); - let asset_path = resolve_asset_path(&config, &std_path); - let handle: Handle = world.get_asset_server().load(asset_path); - block_on_load(world, |w| w.get_asset_server().load_state(&handle))?; + // wrap in the extended material the processing renderer's pipeline expects, so + // the draw systems attach a `MeshMaterial3d` and `material_set` can mutate it + let handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: standard, + extension: ProcessingMaterial { + blend_state: None, + depth_write: None, + }, + }); let entity = world.spawn(UntypedMaterial(handle.untyped())).id(); Ok(entity) } diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index ffbc56c9..4fc9071b 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -11,6 +11,7 @@ use bevy::{ core_pipeline::tonemapping::Tonemapping, ecs::query::QueryEntityError, math::{Mat4, Vec3A}, + post_process::bloom::Bloom, prelude::*, render::{ RenderApp, @@ -31,11 +32,14 @@ use crate::{ render::{ BATCH_INDEX_STEP, RenderState, command::{CommandBuffer, DrawCommand}, + filter, }, surface::Surface, }; use processing_core::error::{ProcessingError, Result}; +pub const DEFAULT_CLEAR_COLOR: Color = Color::srgba_u8(208, 208, 208, 255); + pub struct GraphicsPlugin; impl Plugin for GraphicsPlugin { @@ -115,7 +119,8 @@ impl CameraProjection for ProcessingProjection { fn update(&mut self, _width: f32, _height: f32) { // this gets called with the render target's physical dimensions (i.e. accounting for // scale factor), but our projection is in logical pixel units - // TODO: handle resizes? + self.width = _width; + self.height = _height; } fn far(&self) -> f32 { @@ -205,7 +210,7 @@ pub fn create( ..default() }, target, - // tonemapping prevents color accurate readback, so we disable it + // overridden below for hdr targets Tonemapping::None, // we need to be able to write to the texture CameraMainTextureUsages::default().with(TextureUsages::COPY_DST), @@ -223,9 +228,8 @@ pub fn create( }, )); - // only enable Hdr for floating-point texture formats if is_hdr { - entity_commands.insert(Hdr); + entity_commands.insert((Hdr, Bloom::NATURAL, Tonemapping::TonyMcMapface)); } let entity = entity_commands.id(); @@ -301,11 +305,12 @@ pub fn mode_3d( let fov = std::f32::consts::PI / 3.0; // 60 degrees let aspect = width / height; let camera_z = (height / 2.0) / (fov / 2.0).tan(); - let near = camera_z / 10.0; + // Processing4 uses near = camera_z / 10, but that clips anything closer + // than ~camera_z/10 to the camera. Since `transform_set_position` lets the + // user move the camera without recomputing the projection, a small fixed + // near is safer and matches most engines' defaults. + let near = 1.0; let far = camera_z * 10.0; - - // TODO: Setting this as a default, but we need to think about API around - // a user defined value let near_clip_plane = vec4(0.0, 0.0, -1.0, -near); let mut projection = projections @@ -421,6 +426,68 @@ pub fn ortho( Ok(()) } +pub fn world_from_screen( + In((entity, sx, sy, depth)): In<(Entity, f32, f32, f32)>, + cameras: Query<(&bevy::camera::Camera, &GlobalTransform)>, +) -> Result { + let (camera, transform) = cameras + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + + let ndc_xy = camera + .viewport_to_ndc(Vec2::new(sx, sy)) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + let ndc_z = (1.0 - depth).max(f32::EPSILON); + let world: Vec3 = camera + .ndc_to_world(transform, ndc_xy.extend(ndc_z)) + .ok_or(ProcessingError::GraphicsNotFound)?; + Ok(world) +} + +pub fn set_bloom( + In((entity, intensity, threshold)): In<(Entity, f32, f32)>, + mut commands: Commands, + mut tonemapping_query: Query<&mut Tonemapping>, +) -> Result<()> { + use bevy::post_process::bloom::{Bloom, BloomCompositeMode, BloomPrefilter}; + + let mut bloom = Bloom::NATURAL; + bloom.intensity = intensity; + if threshold > 0.0 { + bloom.composite_mode = BloomCompositeMode::Additive; + bloom.prefilter = BloomPrefilter { + threshold, + threshold_softness: 0.5, + }; + } + + commands.entity(entity).insert((bloom, Hdr)); + + if let Ok(mut tm) = tonemapping_query.get_mut(entity) { + if *tm == Tonemapping::None { + *tm = Tonemapping::TonyMcMapface; + } + } + + Ok(()) +} + +pub fn remove_bloom( + In(entity): In, + mut commands: Commands, + mut tonemapping_query: Query<&mut Tonemapping>, +) -> Result<()> { + use bevy::post_process::bloom::Bloom; + + commands.entity(entity).remove::(); + + if let Ok(mut tm) = tonemapping_query.get_mut(entity) { + *tm = Tonemapping::None; + } + + Ok(()) +} + pub fn destroy( In(entity): In, mut commands: Commands, @@ -444,23 +511,57 @@ pub fn begin_draw(In(entity): In, mut state_query: Query<&mut RenderStat Ok(()) } -pub fn flush(app: &mut App, entity: Entity) -> Result<()> { - // f there's nothing to render, skip the whole render pass. this avoids some issues on - // macos with msaa resolve where nothing is rendered - let is_empty = graphics_mut!(app, entity) - .get::() - .map(|c| c.commands.is_empty()) - .unwrap_or(true); - if is_empty { - return Ok(()); +pub fn get_matrix(In(entity): In, states: Query<&RenderState>) -> Result { + let state = states + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + Ok(Mat4::from(state.transform.current())) +} + +pub fn model_point( + In((entity, point)): In<(Entity, Vec3)>, + states: Query<&RenderState>, +) -> Result { + let state = states + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + Ok(state.transform.transform_point(point)) +} + +pub fn screen_point( + In((entity, point)): In<(Entity, Vec3)>, + query: Query<(&RenderState, &Projection, &Transform, &SurfaceSize)>, +) -> Result { + let (state, projection, camera_transform, size) = query + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + let world = state.transform.transform_point(point); + let clip_from_view = projection.get_clip_from_view(); + let view_from_world = camera_transform.to_matrix().inverse(); + let clip = clip_from_view * view_from_world * world.extend(1.0); + if clip.w == 0.0 { + return Ok(Vec3::ZERO); } + let ndc = clip.truncate() / clip.w; + let SurfaceSize(width, height) = *size; + Ok(Vec3::new( + (ndc.x + 1.0) * 0.5 * width as f32, + (1.0 - ndc.y) * 0.5 * height as f32, + ndc.z, + )) +} +pub fn flush(app: &mut App, entity: Entity) -> Result<()> { graphics_mut!(app, entity).insert(Flush); app.update(); graphics_mut!(app, entity).remove::(); Ok(()) } +pub fn apply_filter(app: &mut App, graphics: Entity, filter: Entity) -> Result<()> { + filter::apply(app, graphics, filter) +} + pub fn present(app: &mut App, entity: Entity) -> Result<()> { graphics_mut!(app, entity) .get_mut::() @@ -483,6 +584,26 @@ pub fn end_draw(app: &mut App, entity: Entity) -> Result<()> { present(app, entity) } +/// Do some work on the GPU to ensure that the render target texture is initialized and can be read +/// from/written to. +/// +/// This is necessary on some platforms (notably macOS) to avoid issues with the first few frames of +/// rendering being corrupted or not appearing at all. +/// +// TODO: why is metal particularly affected by this? can we remove this? +pub fn warmup(app: &mut App, entity: Entity) -> Result<()> { + for _ in 0..3 { + app.world_mut() + .run_system_cached_with( + record_command, + (entity, DrawCommand::BackgroundColor(DEFAULT_CLEAR_COLOR)), + ) + .unwrap()?; + flush(app, entity)?; + } + Ok(()) +} + pub fn record_command( In((graphics_entity, cmd)): In<(Entity, DrawCommand)>, mut graphics_query: Query<&mut CommandBuffer>, diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index 9a4d558b..e7a4fcd7 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -9,6 +9,7 @@ use bevy::{ io::{AssetSourceId, embedded::GetAssetServer}, }, ecs::system::RunSystemOnce, + image::{ImageAddressMode, ImageFilterMode, ImageSampler, ImageSamplerDescriptor}, prelude::*, render::{ RenderApp, @@ -30,7 +31,73 @@ use processing_core::error::{ProcessingError, Result}; pub struct ImagePlugin; impl Plugin for ImagePlugin { - fn build(&self, _app: &mut App) {} + fn build(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +/// Where a blit reads its source pixels: another image, or a graphics render +/// target (via its render-world `ViewTarget`). +pub enum BlitSource { + Image(Handle), + Graphics(Entity), +} + +/// Caches one `wgpu::util::TextureBlitter` per destination texture format. Lives +/// in the render world; blitters are cheap to reuse and expensive to rebuild. +#[derive(Resource, Default)] +pub struct BlitterCache { + blitters: std::collections::HashMap, +} + +/// Blit `source` into the destination image (a sampling copy: handles differing +/// size and format, unlike a raw `copy_texture_to_texture`). Runs in the render +/// world; the destination image's texture is used as the render target. +pub fn blit( + In((dst_handle, source)): In<(Handle, BlitSource)>, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + view_targets: Query<( + &bevy::render::sync_world::MainEntity, + &bevy::render::view::ViewTarget, + )>, + mut cache: ResMut, +) -> Result<()> { + let dst = gpu_images + .get(&dst_handle) + .ok_or(ProcessingError::ImageNotFound)?; + let dst_view: &wgpu::TextureView = &dst.texture_view; + let format = dst.texture_descriptor.format; + + let src_view: &wgpu::TextureView = match &source { + BlitSource::Image(handle) => { + &gpu_images + .get(handle) + .ok_or(ProcessingError::ImageNotFound)? + .texture_view + } + BlitSource::Graphics(entity) => view_targets + .iter() + .find(|(main, _)| ***main == *entity) + .map(|(_, vt)| vt.main_texture_view()) + .ok_or(ProcessingError::GraphicsNotFound)?, + }; + + let device = render_device.wgpu_device(); + let blitter = cache + .blitters + .entry(format) + .or_insert_with(|| wgpu::util::TextureBlitter::new(device, format)); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("processing_blit"), + }); + blitter.copy(device, &mut encoder, src_view, dst_view); + render_queue.submit(std::iter::once(encoder.finish())); + Ok(()) } #[derive(Component)] @@ -330,6 +397,14 @@ pub fn prepare_update_region( Ok((data, px_size)) } +/// Get the pixel dimensions `(width, height)` of an image. +pub fn dimensions(In(entity): In, p_images: Query<&Image>) -> Result<(u32, u32)> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + Ok((p_image.size.width, p_image.size.height)) +} + pub fn destroy( In(entity): In, mut commands: Commands, @@ -466,6 +541,50 @@ pub fn create_readback_buffer( })) } +pub fn set_sampler( + In((entity, filter, wrap_x, wrap_y)): In<(Entity, u8, u8, u8)>, + p_images: Query<&Image>, + mut images: ResMut>, +) -> Result<()> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + + let mut image = images + .get_mut(&p_image.handle) + .ok_or(ProcessingError::ImageNotFound)?; + + let filter_mode = match filter { + 0 => ImageFilterMode::Linear, + 1 => ImageFilterMode::Nearest, + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "unknown filter mode: {filter}" + ))); + } + }; + + let addr_mode = |v: u8| match v { + 0 => Ok(ImageAddressMode::ClampToEdge), + 1 => Ok(ImageAddressMode::Repeat), + 2 => Ok(ImageAddressMode::MirrorRepeat), + _ => Err(ProcessingError::InvalidArgument(format!( + "unknown wrap mode: {v}" + ))), + }; + + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + mag_filter: filter_mode, + min_filter: filter_mode, + mipmap_filter: filter_mode, + address_mode_u: addr_mode(wrap_x)?, + address_mode_v: addr_mode(wrap_y)?, + ..Default::default() + }); + + Ok(()) +} + pub fn gpu_image(app: &mut App, entity: Entity) -> Result<&GpuImage> { let handle = app .world() diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index a1626b19..6359c86f 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2,6 +2,7 @@ pub mod camera; pub mod color; +pub mod compute; pub mod geometry; pub mod gltf; pub mod graphics; @@ -9,12 +10,44 @@ pub mod image; pub mod light; pub mod material; pub mod monitor; +pub mod particles; pub mod render; +pub mod shader_property; +pub mod shader_value; pub mod sketch; pub mod surface; +pub mod text; pub mod time; pub mod transform; +pub use particles::algebra::{ + GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM, MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, + MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, combine, extract, + generate, lookup, map, mix, pack, reduce_components, +}; +pub use particles::compact::compact; +pub use particles::grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; +pub use particles::reduce::{REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce}; +pub use particles::sort::bitonic_sort_by_key; +pub use particles::{ + BOUNDS_CLAMP, BOUNDS_REFLECT, BOUNDS_SOFT, BOUNDS_WRAP, COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, + COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, FALLOFF_CONST, FALLOFF_CUBIC, + FALLOFF_INVERSE, FALLOFF_LINEAR, FALLOFF_QUADRATIC, FALLOFF_SMOOTHSTEP, particles_apply, + particles_attribute_add, particles_buffer, particles_capacity, particles_connectivity_indirect, + particles_create, particles_create_from_geometry, particles_destroy, particles_emit, + particles_emit_gpu, particles_ensure_attribute, particles_flock, particles_gather, + particles_kernel_age, particles_kernel_attr_combine, particles_kernel_attr_linear, + particles_kernel_attr_lookup1d, particles_kernel_attr_lookup2d, particles_kernel_attr_mix, + particles_kernel_attract, particles_kernel_bounds_box, particles_kernel_bounds_geometry, + particles_kernel_bounds_sphere, particles_kernel_drag, particles_kernel_field, + particles_kernel_flock, particles_kernel_force, particles_kernel_impulse, + particles_kernel_integrate, particles_kernel_noise, particles_kernel_orient, + particles_kernel_transform, particles_kernel_vortex, particles_reset_indices, + particles_scatter_create, particles_scatter_volume_create, particles_set_connectivity, + prefix_sum_u32, +}; + use std::path::PathBuf; use bevy::{ @@ -61,9 +94,13 @@ impl Plugin for ProcessingRenderPlugin { material::ProcessingMaterialPlugin, bevy::pbr::wireframe::WireframePlugin::default(), material::custom::CustomMaterialPlugin, + compute::ComputePlugin, + particles::ParticlesPlugin, + render::filter::FilterPlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, + text::font::TextPlugin, )); app.add_systems(First, (clear_transient_meshes, activate_cameras)) @@ -73,6 +110,7 @@ impl Plugin for ProcessingRenderPlugin { flush_draw_commands, add_processing_materials, add_custom_materials, + particles::material::add_particles_materials, ) .chain() .before(AssetEventSystems), @@ -87,12 +125,13 @@ pub fn surface_create_macos( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_macos, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -105,12 +144,13 @@ pub fn surface_create_windows( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_windows, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -124,12 +164,20 @@ pub fn surface_create_wayland( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_wayland, - (window_handle, display_handle, width, height, scale_factor), + ( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ), ) .unwrap() }) @@ -143,17 +191,103 @@ pub fn surface_create_x11( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_x11, - (window_handle, display_handle, width, height, scale_factor), + ( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ), ) .unwrap() }) } +/// Create a WebGPU surface on Linux, auto-detecting Wayland vs. X11 from the +/// environment. +#[cfg(target_os = "linux")] +pub fn surface_create_linux( + window_handle: u64, + display_handle: u64, + width: u32, + height: u32, + scale_factor: f32, + transparent: bool, +) -> error::Result { + // prefer wayland, since x11 may also be available under xwayland + let nonempty = |name| std::env::var_os(name).is_some_and(|v| !v.is_empty()); + let is_wayland = nonempty("WAYLAND_DISPLAY") || nonempty("WAYLAND_SOCKET"); + + #[cfg(all(feature = "wayland", feature = "x11"))] + { + if is_wayland { + surface_create_wayland( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) + } else { + surface_create_x11( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) + } + } + #[cfg(all(feature = "wayland", not(feature = "x11")))] + { + let _ = is_wayland; + surface_create_wayland( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) + } + #[cfg(all(not(feature = "wayland"), feature = "x11"))] + { + let _ = is_wayland; + surface_create_x11( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + ) + } + #[cfg(not(any(feature = "wayland", feature = "x11")))] + { + let _ = ( + window_handle, + display_handle, + width, + height, + scale_factor, + transparent, + is_wayland, + ); + Err(processing_core::error::ProcessingError::InvalidArgument( + "libprocessing was built without `wayland` or `x11` features".into(), + )) + } +} + /// Create a WebGPU surface from a web canvas element pointer. #[cfg(target_arch = "wasm32")] pub fn surface_create_web( @@ -161,12 +295,13 @@ pub fn surface_create_web( width: u32, height: u32, scale_factor: f32, + transparent: bool, ) -> error::Result { app_mut(|app| { app.world_mut() .run_system_cached_with( surface::create_surface_web, - (window_handle, width, height, scale_factor), + (window_handle, width, height, scale_factor, transparent), ) .unwrap() }) @@ -219,7 +354,7 @@ pub fn surface_create_from_canvas( // TODO: not sure if this is right to force here let scale_factor = 1.0; - surface_create_web(canvas_ptr, width, height, scale_factor) + surface_create_web(canvas_ptr, width, height, scale_factor, false) } pub fn surface_destroy(graphics_entity: Entity) -> error::Result<()> { @@ -386,13 +521,17 @@ pub fn graphics_create( height: u32, texture_format: TextureFormat, ) -> error::Result { - app_mut(|app| { - app.world_mut() + app_mut(|app| -> error::Result { + let entity = app + .world_mut() .run_system_cached_with( graphics::create, (width, height, surface_entity, texture_format), ) - .unwrap() + .unwrap()?; + + graphics::warmup(app, entity)?; + Ok(entity) }) } @@ -420,6 +559,90 @@ pub fn graphics_end_draw(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::end_draw(app, graphics_entity)) } +/// Apply a filter to a graphics canvas. +pub fn graphics_apply_filter(graphics_entity: Entity, filter_entity: Entity) -> error::Result<()> { + app_mut(|app| graphics::apply_filter(app, graphics_entity, filter_entity)) +} + +pub fn filter_create(shader_entity: Entity) -> error::Result { + app_mut(|app| render::filter::create(app, shader_entity)) +} + +pub fn filter_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + shader_set(entity, name, value) +} + +pub fn filter_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(render::filter::destroy, entity) + .unwrap() + }) +} + +fn builtin_filter(source: &'static str, passes: u32) -> error::Result { + app_mut(|app| { + if let Some(&filter) = app + .world() + .resource::() + .builtins + .get(source) + { + return Ok(filter); + } + let shader = app + .world_mut() + .run_system_cached_with(material::custom::create_shader, source.to_string()) + .unwrap()?; + let filter = render::filter::create(app, shader)?; + if passes != 1 { + app.world_mut() + .run_system_cached_with(render::filter::set_passes, (filter, passes)) + .unwrap()?; + } + app.world_mut() + .resource_mut::() + .builtins + .insert(source, filter); + Ok(filter) + }) +} + +macro_rules! builtin_filter { + ($name:ident, $src:ident) => { + #[doc = concat!("The built-in `", stringify!($src), "` filter.")] + pub fn $name() -> error::Result { + builtin_filter(render::filter::builtin::$src, 1) + } + }; +} + +builtin_filter!(filter_invert, INVERT); +builtin_filter!(filter_gray, GRAY); +builtin_filter!(filter_threshold, THRESHOLD); +builtin_filter!(filter_posterize, POSTERIZE); +builtin_filter!(filter_opaque, OPAQUE); +builtin_filter!(filter_erode, ERODE); +builtin_filter!(filter_dilate, DILATE); +builtin_filter!(filter_composite, COMPOSITE); +builtin_filter!(filter_feedback, FEEDBACK); + +pub fn filter_set_passes(entity: Entity, passes: u32) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(render::filter::set_passes, (entity, passes)) + .unwrap() + }) +} + +pub fn filter_blur() -> error::Result { + builtin_filter(render::filter::builtin::BLUR, 2) +} + /// Destroy the graphics surface and free its resources. pub fn graphics_destroy(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| { @@ -707,6 +930,42 @@ pub fn graphics_ortho( }) } +pub fn graphics_world_from_screen( + graphics_entity: Entity, + sx: f32, + sy: f32, + depth: f32, +) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + graphics::world_from_screen, + (graphics_entity, sx, sy, depth), + ) + .unwrap() + }) +} + +pub fn graphics_set_bloom( + graphics_entity: Entity, + intensity: f32, + threshold: f32, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::set_bloom, (graphics_entity, intensity, threshold)) + .unwrap() + }) +} + +pub fn graphics_remove_bloom(graphics_entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::remove_bloom, graphics_entity) + .unwrap() + }) +} + pub fn transform_set_position(entity: Entity, position: Vec3) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -787,6 +1046,45 @@ pub fn transform_look_at(entity: Entity, target: Vec3) -> error::Result<()> { }) } +/// Position the camera at `eye`, looking at `center` with the given `up`. +pub fn graphics_camera(entity: Entity, eye: Vec3, center: Vec3, up: Vec3) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(transform::camera, (entity, eye, center, up)) + .unwrap() + }) +} + +/// The current model matrix. Flushes pending draws first. +pub fn graphics_get_matrix(entity: Entity) -> error::Result { + app_mut(|app| { + graphics::flush(app, entity)?; + app.world_mut() + .run_system_cached_with(graphics::get_matrix, entity) + .unwrap() + }) +} + +/// Map a point from the current model space to world space (modelX/Y/Z). +pub fn graphics_model_point(entity: Entity, point: Vec3) -> error::Result { + app_mut(|app| { + graphics::flush(app, entity)?; + app.world_mut() + .run_system_cached_with(graphics::model_point, (entity, point)) + .unwrap() + }) +} + +/// Map a point from the current model space to screen space (screenX/Y/Z). +pub fn graphics_screen_point(entity: Entity, point: Vec3) -> error::Result { + app_mut(|app| { + graphics::flush(app, entity)?; + app.world_mut() + .run_system_cached_with(graphics::screen_point, (entity, point)) + .unwrap() + }) +} + pub fn transform_reset(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -868,6 +1166,48 @@ pub fn image_resize(entity: Entity, new_size: Extent3d) -> error::Result<()> { }) } +/// Get the pixel dimensions `(width, height)` of an image. +pub fn image_size(entity: Entity) -> error::Result<(u32, u32)> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(image::dimensions, entity) + .unwrap() + }) +} + +/// Blit a source into a destination image (a sampling copy that handles differing +/// size/format). `src` is either an image or, when `src_is_graphics`, a graphics +/// render target (which is flushed first so its latest content is copied). +pub fn image_copy_from(dst: Entity, src: Entity, src_is_graphics: bool) -> error::Result<()> { + app_mut(|app| { + let source = if src_is_graphics { + crate::graphics::flush(app, src)?; + image::BlitSource::Graphics(src) + } else { + // Ensure both images are extracted to the render world (a graphics + // source is covered by its flush above; a bare image source is not). + app.update(); + let handle = app + .world() + .get::(src) + .ok_or(error::ProcessingError::ImageNotFound)? + .handle + .clone(); + image::BlitSource::Image(handle) + }; + let dst_handle = app + .world() + .get::(dst) + .ok_or(error::ProcessingError::ImageNotFound)? + .handle + .clone(); + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(image::blit, (dst_handle, source)) + .unwrap() + }) +} + /// Read back image data from GPU to CPU. pub fn image_readback(entity: Entity) -> error::Result> { app_mut(|app| { @@ -929,6 +1269,15 @@ pub fn image_update_region( }) } +/// Set the sampler for an image (filter mode + wrap modes). +pub fn image_set_sampler(entity: Entity, filter: u8, wrap_x: u8, wrap_y: u8) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(image::set_sampler, (entity, filter, wrap_x, wrap_y)) + .unwrap() + }) +} + /// Destroy an existing image and free its resources. pub fn image_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { @@ -1071,6 +1420,38 @@ pub fn geometry_attribute_uv() -> Entity { app_mut(|app| Ok(app.world().resource::().uv)).unwrap() } +pub fn geometry_attribute_rotation() -> Entity { + app_mut(|app| { + Ok(app + .world() + .resource::() + .rotation) + }) + .unwrap() +} + +pub fn geometry_attribute_scale() -> Entity { + app_mut(|app| Ok(app.world().resource::().scale)).unwrap() +} + +pub fn geometry_attribute_life() -> Entity { + app_mut(|app| Ok(app.world().resource::().life)).unwrap() +} + +pub fn geometry_attribute_velocity() -> Entity { + app_mut(|app| { + Ok(app + .world() + .resource::() + .velocity) + }) + .unwrap() +} + +pub fn geometry_attribute_age() -> Entity { + app_mut(|app| Ok(app.world().resource::().age)).unwrap() +} + pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1080,6 +1461,16 @@ pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { }) } +pub fn geometry_attribute_info(entity: Entity) -> error::Result<(String, AttributeFormat)> { + app_mut(|app| { + let attr = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + Ok((attr.name.to_string(), attr.format)) + }) +} + pub fn geometry_create(topology: geometry::Topology) -> error::Result { app_mut(|app| { Ok(app @@ -1339,6 +1730,15 @@ pub fn geometry_set_attribute( }) } +pub fn geometry_create_from_mesh(mesh: Mesh) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::create_from_mesh, mesh) + .unwrap()) + }) +} + pub fn geometry_box(width: f32, height: f32, depth: f32) -> error::Result { app_mut(|app| { Ok(app @@ -1357,12 +1757,25 @@ pub fn geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> error::Result< }) } +/// 3d lattice of `nx * ny * nz` `PointList` vertices centered at the origin, +/// `spacing` units apart. Intended as a position source for +/// [`particles_create_from_geometry`]. +pub fn geometry_grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::create_grid, (nx, ny, nz, spacing)) + .unwrap()) + }) +} + pub fn poll_for_sketch_updates() -> error::Result> { app_mut(|app| { Ok(app .world_mut() .run_system_cached(sketch::sketch_update_handler) - .unwrap()) + .ok() + .flatten()) }) } @@ -1374,9 +1787,25 @@ pub fn shader_create(source: &str) -> error::Result { }) } -/// Load a shader from a file path. +pub fn shader_create_with_features( + source: &str, + features: &[(&str, bool)], +) -> error::Result { + let features: Vec<(String, bool)> = features.iter().map(|(k, v)| (k.to_string(), *v)).collect(); + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + material::custom::create_shader_with_features, + (source.to_string(), features), + ) + .unwrap() + }) +} + +/// load a shader. Accepts either an asset-relative path (`"shaders/foo.wgsl"`) +/// or a URL-scheme asset path (`"embedded://crate/file.wgsl"`). pub fn shader_load(path: &str) -> error::Result { - let path = std::path::PathBuf::from(path); + let path = path.to_string(); app_mut(|app| { app.world_mut() .run_system_cached_with(material::custom::load_shader, path) @@ -1409,10 +1838,124 @@ pub fn material_create_pbr() -> error::Result { }) } +/// `material_create_pbr` with `unlit = true` set on the base StandardMaterial. +pub fn material_create_unlit() -> error::Result { + let entity = material_create_pbr()?; + material_set_unlit(entity, true)?; + Ok(entity) +} + +/// set the albedo source to a constant srgba color. If the material is +/// currently buffer-backed, swaps the asset back to plain PBR while +/// preserving every other `StandardMaterial` field. +// NOTE: constant albedo/emissive are plain PBR uniforms — set them via +// `material_set(entity, "color" | "emissive", Float4(..))`. Only the +// per-particle *buffer* variants are special (see material_set_*_buffer below). + +#[derive(Copy, Clone)] +enum ParticlesBufferSlot { + Albedo, + Emissive, +} + +fn material_set_particles_buffer( + entity: Entity, + buffer_entity: Entity, + slot: ParticlesBufferSlot, +) -> error::Result<()> { + use crate::material::ProcessingMaterial; + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; + use crate::render::material::UntypedMaterial; + use bevy::pbr::ExtendedMaterial; + + type DefaultMat = ExtendedMaterial; + + app_mut(|app| { + let buffer_handle = app + .world() + .get::(buffer_entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let untyped = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::MaterialNotFound)? + .0 + .clone(); + + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); + let mat = mats + .get_mut(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + let ext = &mut mat.into_inner().extension; + match slot { + ParticlesBufferSlot::Albedo => ext.colors = Some(buffer_handle), + ParticlesBufferSlot::Emissive => ext.emissive_colors = Some(buffer_handle), + } + return Ok(()); + } + + let Ok(handle) = untyped.try_typed::() else { + return Err(error::ProcessingError::MaterialNotFound); + }; + let world = app.world_mut(); + let preserved = { + let mut mats = world.resource_mut::>(); + let base = mats + .get(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)? + .base + .clone(); + mats.remove(&handle); + base + }; + let extension = match slot { + ParticlesBufferSlot::Albedo => ParticlesExtension { + colors: Some(buffer_handle), + emissive_colors: None, + }, + ParticlesBufferSlot::Emissive => ParticlesExtension { + colors: None, + emissive_colors: Some(buffer_handle), + }, + }; + let new_handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: preserved, + extension, + }); + world + .entity_mut(entity) + .insert(UntypedMaterial(new_handle.untyped())); + Ok(()) + }) +} + +pub fn material_set_albedo_buffer( + entity: Entity, + color_buffer_entity: Entity, +) -> error::Result<()> { + material_set_particles_buffer(entity, color_buffer_entity, ParticlesBufferSlot::Albedo) +} + +pub fn material_set_emissive_buffer( + entity: Entity, + emissive_buffer_entity: Entity, +) -> error::Result<()> { + material_set_particles_buffer( + entity, + emissive_buffer_entity, + ParticlesBufferSlot::Emissive, + ) +} + pub fn material_set( entity: Entity, name: impl Into, - value: material::MaterialValue, + value: shader_value::ShaderValue, ) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1421,6 +1964,49 @@ pub fn material_set( }) } +pub fn material_set_alpha_mode(entity: Entity, mode: u8, cutoff: f32) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_alpha_mode, (entity, mode, cutoff)) + .unwrap() + }) +} + +pub fn material_set_double_sided(entity: Entity, value: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_double_sided, (entity, value)) + .unwrap() + }) +} + +pub fn material_set_unlit(entity: Entity, value: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_unlit, (entity, value)) + .unwrap() + }) +} + +pub fn material_set_depth_write(entity: Entity, value: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_depth_write, (entity, value)) + .unwrap() + }) +} + +pub fn material_set_custom_blend( + entity: Entity, + blend: bevy::render::render_resource::BlendState, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_custom_blend, (entity, blend)) + .unwrap() + }) +} + pub fn material_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1465,6 +2051,24 @@ pub fn surface_physical_height(entity: Entity) -> error::Result { }) } +pub fn surface_width(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(surface::width, entity) + .unwrap()) + }) +} + +pub fn surface_height(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(surface::height, entity) + .unwrap()) + }) +} + pub fn monitor_list() -> error::Result> { app_mut(|app| Ok(app.world_mut().run_system_cached(monitor::list).unwrap())) } @@ -1629,3 +2233,635 @@ pub fn gltf_light(gltf_entity: Entity, index: usize) -> error::Result { .unwrap() }) } + +pub fn buffer_create(size: u64) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer, size) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_create_with_usage( + size: u64, + extra_usage: bevy::render::render_resource::BufferUsages, +) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer_with_usage, (size, extra_usage)) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_create_with_data(data: Vec) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer_with_data, data) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_size(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size) + }) +} + +pub fn buffer_write(entity: Entity, data: Vec) -> error::Result<()> { + buffer_write_range(entity, 0, data, true) +} + +pub fn buffer_write_element(entity: Entity, offset: u64, data: Vec) -> error::Result<()> { + buffer_write_range(entity, offset, data, false) +} + +fn ensure_buffer_synced(app: &mut App, entity: Entity) -> error::Result<()> { + let (handle, readback_buffer, size, synced) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + ( + buf.handle.clone(), + buf.readback_buffer.clone(), + buf.size, + buf.synced, + ) + }; + if synced { + return Ok(()); + } + let bytes = app + .sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with( + compute::read_buffer_gpu, + (handle.clone(), readback_buffer, size), + ) + .unwrap()?; + + let world = app.world_mut(); + { + let mut buffers = world.resource_mut::>(); + let asset = buffers + .get_mut_untracked(handle.id()) + .ok_or(error::ProcessingError::BufferNotFound)?; + asset.data = Some(bytes); + } + + let mut buf = world + .get_mut::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + buf.synced = true; + Ok(()) +} + +fn buffer_write_range( + entity: Entity, + offset: u64, + data: Vec, + exact_size: bool, +) -> error::Result<()> { + app_mut(|app| { + let (handle, size) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + (buf.handle.clone(), buf.size) + }; + let end = offset.checked_add(data.len() as u64).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if exact_size && (offset != 0 || end != size) { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer_write data length {} does not match buffer size {size}; \ + destroy and re-create to resize, or use buffer_write_element for partial writes", + data.len() + ))); + } + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer write out of bounds: offset {offset} + len {} > size {size}", + data.len() + ))); + } + ensure_buffer_synced(app, entity)?; + app.world_mut() + .run_system_cached_with(compute::write_buffer_cpu, (handle, offset, data)) + .unwrap() + }) +} + +pub fn buffer_read_element(entity: Entity, offset: u64, len: u64) -> error::Result> { + buffer_read_range(entity, offset, len) +} + +pub fn buffer_read(entity: Entity) -> error::Result> { + let size = buffer_size(entity)?; + buffer_read_range(entity, 0, size) +} + +fn buffer_read_range(entity: Entity, offset: u64, len: u64) -> error::Result> { + app_mut(|app| { + let size = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size; + let end = offset.checked_add(len).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer read out of bounds: offset {offset} + len {len} > size {size}" + ))); + } + ensure_buffer_synced(app, entity)?; + let handle = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let buffers = app + .world() + .resource::>(); + let data = buffers + .get(&handle) + .and_then(|a| a.data.as_ref()) + .ok_or(error::ProcessingError::BufferNotFound)?; + Ok(data[offset as usize..(offset + len) as usize].to_vec()) + }) +} + +pub fn buffer_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_buffer, entity) + .unwrap() + }) +} + +pub fn compute_create(shader_entity: Entity) -> error::Result { + app_mut(|app| compute::create_compute(app, shader_entity)) +} + +pub fn shader_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + let name = name.into(); + app_mut(|app| { + let handled = app + .world_mut() + .run_system_cached_with( + shader_property::set_property, + (entity, name.clone(), value.clone()), + ) + .unwrap()?; + if handled { + return Ok(()); + } + app.world_mut() + .run_system_cached_with(material::set_property, (entity, name, value)) + .unwrap() + }) +} + +pub fn compute_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + shader_set(entity, name, value) +} + +pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { + app_mut(|app| { + app.update(); + + let args = { + let world = app.world(); + let c = world + .get::(entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + let mesh_bindings = compute::resolve_mesh_bindings(world, c)?; + ( + c.pipeline_id, + c.bind_group_layout_descriptors.clone(), + c.shader.clone(), + mesh_bindings, + x, + y, + z, + ) + }; + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(compute::dispatch, args) + .unwrap() + }) +} + +pub fn compute_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_compute, entity) + .unwrap() + }) +} + +// --- Font API --- + +/// Load a font file and return a font entity handle. +/// +/// Reading fonts from the filesystem is not available on wasm; callers should +/// register font bytes through another path there. +pub fn font_load(path: &str) -> error::Result { + use text::font::{Font, TextContext}; + + #[cfg(target_arch = "wasm32")] + { + let _ = path; + return Err(error::ProcessingError::FontLoadError( + "loading fonts from a file is not supported on wasm".to_string(), + )); + } + + #[cfg(not(target_arch = "wasm32"))] + { + let data = std::fs::read(path) + .map_err(|e| error::ProcessingError::FontLoadError(format!("{}: {}", path, e)))?; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + let family_name = + text_cx + .load_font(data) + .ok_or(error::ProcessingError::FontLoadError( + "Could not determine font family name".to_string(), + ))?; + let entity = app.world_mut().spawn(Font { family_name }).id(); + Ok(entity) + }) + } +} + +/// Create a font handle from an existing font family name. +pub fn font_create(name: &str) -> error::Result { + use text::font::{Font, TextContext}; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + if !text_cx.has_font(name) { + return Err(error::ProcessingError::FontNotFound); + } + let entity = app + .world_mut() + .spawn(Font { + family_name: name.to_string(), + }) + .id(); + Ok(entity) + }) +} + +/// List all available font family names (system + registered). +pub fn font_list() -> error::Result> { + use text::font::TextContext; + + app_mut(|app| { + let text_cx = app.world().resource::().clone(); + Ok(text_cx.list_fonts()) + }) +} + +/// Query variable font axes for a loaded font. +pub fn font_variations(font_entity: Entity) -> error::Result> { + use text::font::TextContext; + + app_mut(|app| { + let font = app.world().get::(font_entity).ok_or( + error::ProcessingError::InvalidArgument("Invalid font entity".to_string()), + )?; + let family = font.family_name.clone(); + let text_cx = app.world().resource::().clone(); + Ok(text_cx.font_variations(&family)) + }) +} + +/// Query font metadata for a loaded font. +pub fn font_metadata(font_entity: Entity) -> error::Result { + use text::font::TextContext; + + app_mut(|app| { + let font = app.world().get::(font_entity).ok_or( + error::ProcessingError::InvalidArgument("Invalid font entity".to_string()), + )?; + let family = font.family_name.clone(); + let text_cx = app.world().resource::().clone(); + text_cx + .font_metadata(&family) + .ok_or(error::ProcessingError::InvalidArgument(format!( + "Font family '{}' not found", + family + ))) + }) +} + +// --- Text API --- + +pub fn graphics_text_font( + graphics_entity: Entity, + font_entity: Option, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextFont(font_entity)) +} + +pub fn graphics_text_style(graphics_entity: Entity, style: u8) -> error::Result<()> { + use render::command::TextStyle; + graphics_record_command( + graphics_entity, + DrawCommand::TextStyle(TextStyle::from(style)), + ) +} + +pub fn graphics_text_align(graphics_entity: Entity, h: u8, v: u8) -> error::Result<()> { + use render::command::{TextAlignH, TextAlignV}; + graphics_record_command( + graphics_entity, + DrawCommand::TextAlign { + h: TextAlignH::from(h), + v: TextAlignV::from(v), + }, + ) +} + +pub fn graphics_text_wrap(graphics_entity: Entity, mode: u8) -> error::Result<()> { + use render::command::TextWrapMode; + graphics_record_command( + graphics_entity, + DrawCommand::TextWrap(TextWrapMode::from(mode)), + ) +} + +pub fn graphics_text_weight(graphics_entity: Entity, weight: f32) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextWeight(weight)) +} + +fn parse_tag(tag: &str) -> error::Result<[u8; 4]> { + let bytes = tag.as_bytes(); + if bytes.len() != 4 { + return Err(error::ProcessingError::InvalidArgument(format!( + "Font tag must be exactly 4 characters, got '{}'", + tag + ))); + } + Ok([bytes[0], bytes[1], bytes[2], bytes[3]]) +} + +pub fn graphics_text_variation( + graphics_entity: Entity, + tag: &str, + value: f32, +) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::TextVariation { tag, value }) +} + +pub fn graphics_clear_text_variations(graphics_entity: Entity) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::ClearTextVariations) +} + +pub fn graphics_text_feature(graphics_entity: Entity, tag: &str, value: u16) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::TextFeature { tag, value }) +} + +pub fn graphics_no_text_feature(graphics_entity: Entity, tag: &str) -> error::Result<()> { + let tag = parse_tag(tag)?; + graphics_record_command(graphics_entity, DrawCommand::NoTextFeature { tag }) +} + +pub fn graphics_clear_text_features(graphics_entity: Entity) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::ClearTextFeatures) +} + +pub fn graphics_text_glyph_colors( + graphics_entity: Entity, + colors: Vec, +) -> error::Result<()> { + graphics_record_command(graphics_entity, DrawCommand::TextGlyphColors(colors)) +} + +/// Snapshot a graphics entity's text state and the shared `TextContext`. +fn text_query_state( + app: &App, + graphics_entity: Entity, + max_w: Option, + max_h: Option, +) -> error::Result<( + render::primitive::text::OwnedTextParams, + text::font::TextContext, +)> { + let state = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + let params = render::primitive::text::OwnedTextParams::from_render_state(state, max_w, max_h); + let text_cx = app.world().resource::().clone(); + Ok((params, text_cx)) +} + +pub fn graphics_text_to_paths( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, +) -> error::Result>> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_paths( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_to_contours( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, +) -> error::Result>> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_contours( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_to_points( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + sample_factor: Option, +) -> error::Result> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_points( + content, + x, + y, + sample_factor.unwrap_or(render::primitive::text::DEFAULT_SAMPLE_FACTOR), + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_to_model( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + depth: f32, +) -> error::Result { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_to_model( + content, + x, + y, + depth, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_width(graphics_entity: Entity, content: &str) -> error::Result { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_width( + content, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_ascent(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_ascent( + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_descent(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_descent( + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_bounds( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result<[f32; 4]> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_bounds( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_line_count(graphics_entity: Entity, content: &str) -> error::Result { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, None, None)?; + Ok(render::primitive::text::text_line_count( + content, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_lines( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_lines( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) + }) +} + +pub fn graphics_text_glyph_rects( + graphics_entity: Entity, + content: &str, + x: f32, + y: f32, + max_w: Option, + max_h: Option, +) -> error::Result> { + app_mut(|app| { + let (params, text_cx) = text_query_state(app, graphics_entity, max_w, max_h)?; + Ok(render::primitive::text::text_glyph_rects( + content, + x, + y, + ¶ms.as_params(), + &text_cx, + )) + }) +} diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 04080b8c..f39de9d7 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -38,7 +38,7 @@ use bevy::{ render_asset::RenderAssets, render_phase::DrawFunctions, render_resource::{ - BindGroupLayoutDescriptor, BindingResources, BlendState, UnpreparedBindGroup, + BindGroupLayoutDescriptor, BindingResources, BlendState, Face, UnpreparedBindGroup, }, renderer::RenderDevice, storage::GpuShaderBuffer, @@ -51,14 +51,16 @@ use bevy_naga_reflect::dynamic_shader::DynamicShader; use bevy::shader::Shader as ShaderAsset; -use crate::material::MaterialValue; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use processing_core::config::{Config, ConfigKey}; use processing_core::error::{ProcessingError, Result}; #[derive(Clone, Hash, PartialEq)] struct CustomMaterialKey { blend_state: Option, + double_sided: Option, + depth_write: Option, } fn specialize( @@ -67,12 +69,10 @@ fn specialize( _layout: &MeshVertexBufferLayoutRef, _pipeline_key: ErasedMaterialPipelineKey, ) -> std::result::Result<(), SpecializedMeshPipelineError> { - if let Some(key) = key.downcast_ref::() - && let Some(blend_state) = key.blend_state - && let Some(fragment_state) = &mut descriptor.fragment - { - for target in fragment_state.targets.iter_mut().flatten() { - target.blend = Some(blend_state); + if let Some(key) = key.downcast_ref::() { + crate::material::apply_pipeline_state(descriptor, key.blend_state, key.depth_write); + if let Some(double_sided) = key.double_sided { + descriptor.primitive.cull_mode = if double_sided { None } else { Some(Face::Back) }; } } Ok(()) @@ -85,6 +85,9 @@ pub struct CustomMaterial { pub has_vertex: bool, pub has_fragment: bool, pub blend_state: Option, + pub alpha_mode: AlphaMode, + pub double_sided: Option, + pub depth_write: Option, } #[derive(Component)] @@ -113,19 +116,19 @@ impl wesl::Resolver for ProcessingResolver<'_> { &'a self, path: &ModulePath, ) -> std::result::Result, wesl::ResolveError> { - // Check for the entry module first (its parsed path has Package origin) + // check for the entry module first (its parsed path has Package origin) if path.to_string() == "entry" { return Ok(Cow::Borrowed(self.entry_source)); } match &path.origin { PathOrigin::Package(pkg) => { - // Self-referential package imports: within a package, imports to - // the same package stack the name (e.g. "lygia/lygia/lygia/..."). - // Collapse to the root package name before resolving. - let root = pkg.split('/').next().unwrap(); + // wesl encodes a cross-package import as a synthetic "parent/child" + // origin (e.g. importing processing from the entry module yields + // "entry/processing"). The real package is the leaf. + let leaf = pkg.rsplit('/').next().unwrap(); let mut fixed = path.clone(); - fixed.origin = PathOrigin::Package(root.to_string()); + fixed.origin = PathOrigin::Package(leaf.to_string()); self.pkg_resolver.resolve_source(&fixed) } _ => Err(wesl::ResolveError::ModuleNotFound( @@ -136,7 +139,14 @@ impl wesl::Resolver for ProcessingResolver<'_> { } } -fn compile_shader(source: &str) -> Result<(String, naga::Module)> { +pub(crate) fn compile_shader(source: &str) -> Result<(String, naga::Module)> { + compile_shader_with_features(source, &[]) +} + +pub(crate) fn compile_shader_with_features( + source: &str, + features: &[(&str, bool)], +) -> Result<(String, naga::Module)> { let mut pkg_resolver = PkgResolver::new(); pkg_resolver.add_package(&processing::PACKAGE); pkg_resolver.add_package(&lygia::PACKAGE); @@ -146,11 +156,17 @@ fn compile_shader(source: &str) -> Result<(String, naga::Module)> { pkg_resolver, }; let module_path: ModulePath = "entry".parse().unwrap(); - let options = wesl::CompileOptions { + let mut options = wesl::CompileOptions { imports: true, strip: false, ..Default::default() }; + for (name, enabled) in features { + options + .features + .flags + .insert((*name).to_string(), (*enabled).into()); + } let compiled = wesl::compile(&module_path, &resolver, &wesl::EscapeMangler, &options) .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; let wgsl = compiled.to_string(); @@ -174,19 +190,42 @@ pub fn create_shader( .id()) } -pub fn load_shader(In(path): In, world: &mut World) -> Result { +pub fn create_shader_with_features( + In((source, features)): In<(String, Vec<(String, bool)>)>, + mut commands: Commands, + mut shaders: ResMut>, +) -> Result { + let feats: Vec<(&str, bool)> = features.iter().map(|(k, v)| (k.as_str(), *v)).collect(); + let (compiled_wgsl, module) = compile_shader_with_features(&source, &feats)?; + let shader_handle = shaders.add(ShaderAsset::from_wgsl(compiled_wgsl, "custom_material")); + Ok(commands + .spawn(Shader { + module, + shader_handle, + }) + .id()) +} + +pub fn load_shader(In(path): In, world: &mut World) -> Result { use bevy::asset::{ AssetPath, LoadState, handle_internal_asset_events, io::{AssetSourceId, embedded::GetAssetServer}, }; use bevy::ecs::system::RunSystemOnce; - let config = world.resource::(); - let asset_path: AssetPath = match config.get(ConfigKey::AssetRootPath) { - Some(_) => { - AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + // url-scheme paths (e.g. `embedded://crate/foo.wgsl`) carry their own + // source; relative paths route through the configured asset directory + let asset_path: AssetPath = if path.contains("://") { + AssetPath::parse(&path).into_owned() + } else { + let config = world.resource::(); + let path = std::path::PathBuf::from(path); + match config.get(ConfigKey::AssetRootPath) { + Some(_) => { + AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + } + None => AssetPath::from_path_buf(path), } - None => AssetPath::from_path_buf(path), }; let handle: Handle = world.get_asset_server().load(asset_path); @@ -260,57 +299,215 @@ pub fn create_custom( has_vertex, has_fragment, blend_state: None, + alpha_mode: AlphaMode::Opaque, + double_sided: None, + depth_write: None, }; let handle = custom_materials.add(material); Ok(commands.spawn(UntypedMaterial(handle.untyped())).id()) } -pub fn set_property( - material: &mut CustomMaterial, +pub fn set_property(material: &mut CustomMaterial, name: &str, value: &ShaderValue) -> Result<()> { + let reflect_value: Box = shader_value_to_reflect(value)?; + apply_reflect_field(&mut material.shader, name, &*reflect_value) +} + +pub(crate) fn apply_reflect_field( + shader: &mut DynamicShader, name: &str, - value: &MaterialValue, + value: &dyn PartialReflect, ) -> Result<()> { - let reflect_value: Box = material_value_to_reflect(value)?; - - if let Some(field) = material.shader.field_mut(name) { - field.apply(&*reflect_value); + if let Some(field) = shader.field_mut(name) { + apply_field_coerced(field, value); return Ok(()); } - let param_name = find_param_containing_field(&material.shader, name); + let param_name = find_param_containing_field(shader, name); if let Some(param_name) = param_name - && let Some(param) = material.shader.field_mut(¶m_name) + && let Some(param) = shader.field_mut(¶m_name) && let ReflectMut::Struct(s) = param.reflect_mut() && let Some(field) = s.field_mut(name) { - field.apply(&*reflect_value); + apply_field_coerced(field, value); return Ok(()); } - Err(ProcessingError::UnknownMaterialProperty(name.to_string())) + Err(ProcessingError::UnknownShaderProperty(name.to_string())) +} + +fn apply_field_coerced(field: &mut dyn PartialReflect, value: &dyn PartialReflect) { + if let Some(coerced) = coerce_numeric(field, value) { + field.apply(coerced.as_ref()); + } else { + field.apply(value); + } +} + +fn coerce_numeric( + target: &dyn PartialReflect, + value: &dyn PartialReflect, +) -> Option> { + fn scalar_f32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as f32) + } else { + v.try_downcast_ref::().map(|x| *x as f32) + } + } + fn scalar_i32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as i32) + } else { + v.try_downcast_ref::().map(|x| *x as i32) + } + } + fn scalar_u32(v: &dyn PartialReflect) -> Option { + if let Some(x) = v.try_downcast_ref::() { + Some(*x) + } else if let Some(x) = v.try_downcast_ref::() { + Some(*x as u32) + } else { + v.try_downcast_ref::().map(|x| *x as u32) + } + } + fn comps(v: &dyn PartialReflect) -> Option<[f64; N]> { + macro_rules! from { + ($ty:ty) => { + if let Some(x) = v.try_downcast_ref::<$ty>() { + let a = x.to_array(); + let mut out = [0f64; N]; + for i in 0..N { + out[i] = a[i] as f64; + } + return Some(out); + } + }; + } + match N { + 2 => { + from!(Vec2); + from!(IVec2); + from!(UVec2); + } + 3 => { + from!(Vec3); + from!(IVec3); + from!(UVec3); + } + 4 => { + from!(Vec4); + from!(IVec4); + from!(UVec4); + } + _ => {} + } + None + } + + if target.try_downcast_ref::().is_some() { + return scalar_f32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return scalar_i32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return scalar_u32(value).map(|x| Box::new(x) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(Vec2::new(c[0] as f32, c[1] as f32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(IVec2::new(c[0] as i32, c[1] as i32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<2>(value) + .map(|c| Box::new(UVec2::new(c[0] as u32, c[1] as u32)) as Box); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(Vec3::new(c[0] as f32, c[1] as f32, c[2] as f32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(IVec3::new(c[0] as i32, c[1] as i32, c[2] as i32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<3>(value).map(|c| { + Box::new(UVec3::new(c[0] as u32, c[1] as u32, c[2] as u32)) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(Vec4::new( + c[0] as f32, + c[1] as f32, + c[2] as f32, + c[3] as f32, + )) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(IVec4::new( + c[0] as i32, + c[1] as i32, + c[2] as i32, + c[3] as i32, + )) as Box + }); + } + if target.try_downcast_ref::().is_some() { + return comps::<4>(value).map(|c| { + Box::new(UVec4::new( + c[0] as u32, + c[1] as u32, + c[2] as u32, + c[3] as u32, + )) as Box + }); + } + None } -fn material_value_to_reflect(value: &MaterialValue) -> Result> { +pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result> { Ok(match value { - MaterialValue::Float(v) => Box::new(*v), - MaterialValue::Float2(v) => Box::new(Vec2::from_array(*v)), - MaterialValue::Float3(v) => Box::new(Vec3::from_array(*v)), - MaterialValue::Float4(v) => Box::new(Vec4::from_array(*v)), - MaterialValue::Int(v) => Box::new(*v), - MaterialValue::Int2(v) => Box::new(IVec2::from_array(*v)), - MaterialValue::Int3(v) => Box::new(IVec3::from_array(*v)), - MaterialValue::Int4(v) => Box::new(IVec4::from_array(*v)), - MaterialValue::UInt(v) => Box::new(*v), - MaterialValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), - MaterialValue::Texture(_) => { - return Err(ProcessingError::UnknownMaterialProperty( - "Texture properties not yet supported for custom materials".to_string(), + ShaderValue::Float(v) => Box::new(*v), + ShaderValue::Float2(v) => Box::new(Vec2::from_array(*v)), + ShaderValue::Float3(v) => Box::new(Vec3::from_array(*v)), + ShaderValue::Float4(v) => Box::new(Vec4::from_array(*v)), + ShaderValue::Int(v) => Box::new(*v), + ShaderValue::Int2(v) => Box::new(IVec2::from_array(*v)), + ShaderValue::Int3(v) => Box::new(IVec3::from_array(*v)), + ShaderValue::Int4(v) => Box::new(IVec4::from_array(*v)), + ShaderValue::UInt(v) => Box::new(*v), + ShaderValue::UInt2(v) => Box::new(UVec2::from_array(*v)), + ShaderValue::UInt3(v) => Box::new(UVec3::from_array(*v)), + ShaderValue::UInt4(v) => Box::new(UVec4::from_array(*v)), + ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), + ShaderValue::Texture(_) + | ShaderValue::Buffer(_) + | ShaderValue::MeshAttribute(..) + | ShaderValue::MeshIndex(_) => { + return Err(ProcessingError::InvalidArgument( + "Texture/Buffer/Mesh* must be bound via set_property, not as a uniform value" + .to_string(), )); } }) } -fn find_param_containing_field(shader: &DynamicShader, field_name: &str) -> Option { +pub(crate) fn find_param_containing_field( + shader: &DynamicShader, + field_name: &str, +) -> Option { for i in 0..shader.field_len() { if let Some(field) = shader.field_at(i) && let ReflectRef::Struct(s) = field.reflect_ref() @@ -438,12 +635,19 @@ impl ErasedRenderAsset for CustomMaterial { mesh_pipeline_key_bits: ErasedMeshPipelineKey::new(MeshPipelineKey::empty()), base_specialize: Some(base_specialize), material_layout: Some(bind_group_layout), - material_key: ErasedMaterialKey::new(CustomMaterialKey { blend_state }), + material_key: ErasedMaterialKey::new(CustomMaterialKey { + blend_state, + double_sided: source_asset.double_sided, + depth_write: source_asset.depth_write, + }), user_specialize: Some(specialize), + // A custom blend forces the sorted transparent phase (an arbitrary + // blend equation can't be assumed commutative); otherwise honor the + // explicitly-set alpha mode. alpha_mode: if blend_state.is_some() { AlphaMode::Blend } else { - AlphaMode::Opaque + source_asset.alpha_mode }, ..Default::default() }; diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index be48836a..f449ab7d 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -1,7 +1,9 @@ pub mod custom; pub mod pbr; +use crate::compute; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use bevy::material::descriptor::RenderPipelineDescriptor; use bevy::material::specialize::SpecializedMeshPipelineError; use bevy::mesh::MeshVertexBufferLayoutRef; @@ -9,8 +11,9 @@ use bevy::pbr::{ ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline, }; use bevy::prelude::*; -use bevy::render::render_resource::{AsBindGroup, BlendState}; +use bevy::render::render_resource::{AsBindGroup, BlendState, Face}; use bevy::shader::ShaderRef; +use bevy_naga_reflect::reflect::ParameterCategory; use processing_core::error::{self, ProcessingError}; pub struct ProcessingMaterialPlugin; @@ -38,21 +41,6 @@ impl Plugin for ProcessingMaterialPlugin { #[derive(Resource)] pub struct DefaultMaterial(pub Entity); -#[derive(Debug, Clone)] -pub enum MaterialValue { - Float(f32), - Float2([f32; 2]), - Float3([f32; 3]), - Float4([f32; 4]), - Int(i32), - Int2([i32; 2]), - Int3([i32; 3]), - Int4([i32; 4]), - UInt(u32), - Mat4([f32; 16]), - Texture(Entity), -} - pub fn create_pbr( mut commands: Commands, mut materials: ResMut>>, @@ -63,17 +51,34 @@ pub fn create_pbr( cull_mode: None, ..default() }, - extension: ProcessingMaterial { blend_state: None }, + extension: ProcessingMaterial { + blend_state: None, + depth_write: None, + }, }); commands.spawn(UntypedMaterial(handle.untyped())).id() } pub fn set_property( - In((entity, name, value)): In<(Entity, String, MaterialValue)>, + In((entity, name, value)): In<(Entity, String, ShaderValue)>, material_handles: Query<&UntypedMaterial>, + images: Query<&crate::image::Image>, mut extended_materials: ResMut>>, + mut particles_materials: ResMut>, mut custom_materials: ResMut>, + mut p_buffers: Query<&mut compute::Buffer>, ) -> error::Result<()> { + let texture_handle = match &value { + ShaderValue::Texture(img_entity) => Some( + images + .get(*img_entity) + .map_err(|_| ProcessingError::ImageNotFound)? + .handle + .clone(), + ), + _ => None, + }; + let untyped = material_handles .get(entity) .map_err(|_| ProcessingError::MaterialNotFound)?; @@ -86,19 +91,273 @@ pub fn set_property( let mut extended = extended_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; - return pbr::set_property(&mut extended.base, &name, &value); + return pbr::set_property(&mut extended.base, &name, &value, texture_handle); + } + + if let Ok(handle) = untyped + .0 + .clone() + .try_typed::() + { + let mut extended = particles_materials + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)?; + return pbr::set_property(&mut extended.base, &name, &value, texture_handle); } if let Ok(handle) = untyped.0.clone().try_typed::() { let mut mat = custom_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; + + if let ShaderValue::Buffer(buf_entity) = &value { + let mut buffer = p_buffers + .get_mut(*buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + + let category = mat + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Buffer" + ))); + }; + mat.shader.insert(&name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; + } + return Ok(()); + } + return custom::set_property(&mut mat, &name, &value); } Err(ProcessingError::MaterialNotFound) } +type PbrMaterial = ExtendedMaterial; + +enum MaterialMut<'a> { + Pbr(&'a mut PbrMaterial), + Particles(&'a mut crate::particles::material::ParticlesMaterial), + Custom(&'a mut custom::CustomMaterial), +} + +fn edit_material( + entity: Entity, + material_handles: &Query<&UntypedMaterial>, + pbr: &mut Assets, + particles: &mut Assets, + custom: &mut Assets, + f: impl FnOnce(MaterialMut) -> error::Result<()>, +) -> error::Result<()> { + let untyped = material_handles + .get(entity) + .map_err(|_| ProcessingError::MaterialNotFound)?; + if let Ok(handle) = untyped.0.clone().try_typed::() { + let mat = pbr + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)? + .into_inner(); + return f(MaterialMut::Pbr(mat)); + } + if let Ok(handle) = untyped + .0 + .clone() + .try_typed::() + { + let mat = particles + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)? + .into_inner(); + return f(MaterialMut::Particles(mat)); + } + if let Ok(handle) = untyped.0.clone().try_typed::() { + let mat = custom + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)? + .into_inner(); + return f(MaterialMut::Custom(mat)); + } + Err(ProcessingError::MaterialNotFound) +} + +fn resolve_alpha_mode(mode: u8, cutoff: f32) -> error::Result { + Ok(match mode { + 0 => AlphaMode::Opaque, + 1 => AlphaMode::Mask(cutoff), + 2 => AlphaMode::Blend, + 3 => AlphaMode::Premultiplied, + 4 => AlphaMode::Add, + 5 => AlphaMode::Multiply, + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "unknown alpha_mode value: {mode}" + ))); + } + }) +} + +fn set_base_double_sided(base: &mut StandardMaterial, value: bool) { + base.double_sided = value; + base.cull_mode = if value { None } else { Some(Face::Back) }; +} + +pub fn set_alpha_mode( + In((entity, mode, cutoff)): In<(Entity, u8, f32)>, + material_handles: Query<&UntypedMaterial>, + mut pbr: ResMut>, + mut particles: ResMut>, + mut custom: ResMut>, +) -> error::Result<()> { + let alpha_mode = resolve_alpha_mode(mode, cutoff)?; + let opaque = matches!(alpha_mode, AlphaMode::Opaque); + edit_material( + entity, + &material_handles, + &mut pbr, + &mut particles, + &mut custom, + |m| { + match m { + MaterialMut::Pbr(mat) => { + mat.base.alpha_mode = alpha_mode; + if opaque { + mat.extension.blend_state = None; + } + } + MaterialMut::Particles(mat) => mat.base.alpha_mode = alpha_mode, + MaterialMut::Custom(mat) => { + mat.alpha_mode = alpha_mode; + if opaque { + mat.blend_state = None; + } + } + } + Ok(()) + }, + ) +} + +pub fn set_double_sided( + In((entity, value)): In<(Entity, bool)>, + material_handles: Query<&UntypedMaterial>, + mut pbr: ResMut>, + mut particles: ResMut>, + mut custom: ResMut>, +) -> error::Result<()> { + edit_material( + entity, + &material_handles, + &mut pbr, + &mut particles, + &mut custom, + |m| { + match m { + MaterialMut::Pbr(mat) => set_base_double_sided(&mut mat.base, value), + MaterialMut::Particles(mat) => set_base_double_sided(&mut mat.base, value), + MaterialMut::Custom(mat) => mat.double_sided = Some(value), + } + Ok(()) + }, + ) +} + +pub fn set_unlit( + In((entity, value)): In<(Entity, bool)>, + material_handles: Query<&UntypedMaterial>, + mut pbr: ResMut>, + mut particles: ResMut>, + mut custom: ResMut>, +) -> error::Result<()> { + edit_material( + entity, + &material_handles, + &mut pbr, + &mut particles, + &mut custom, + |m| match m { + MaterialMut::Pbr(mat) => { + mat.base.unlit = value; + Ok(()) + } + MaterialMut::Particles(mat) => { + mat.base.unlit = value; + Ok(()) + } + MaterialMut::Custom(_) => Err(ProcessingError::InvalidArgument( + "unlit is not applicable to custom-shader materials; the shader defines lighting" + .to_string(), + )), + }, + ) +} + +pub fn set_depth_write( + In((entity, value)): In<(Entity, bool)>, + material_handles: Query<&UntypedMaterial>, + mut pbr: ResMut>, + mut particles: ResMut>, + mut custom: ResMut>, +) -> error::Result<()> { + edit_material( + entity, + &material_handles, + &mut pbr, + &mut particles, + &mut custom, + |m| match m { + MaterialMut::Pbr(mat) => { + mat.extension.depth_write = Some(value); + Ok(()) + } + MaterialMut::Particles(_) => Err(ProcessingError::InvalidArgument( + "depth-write is not yet configurable on particle materials".to_string(), + )), + MaterialMut::Custom(mat) => { + mat.depth_write = Some(value); + Ok(()) + } + }, + ) +} + +pub fn set_custom_blend( + In((entity, blend)): In<(Entity, BlendState)>, + material_handles: Query<&UntypedMaterial>, + mut pbr: ResMut>, + mut particles: ResMut>, + mut custom: ResMut>, +) -> error::Result<()> { + edit_material( + entity, + &material_handles, + &mut pbr, + &mut particles, + &mut custom, + |m| match m { + MaterialMut::Pbr(mat) => { + mat.extension.blend_state = Some(blend); + mat.base.alpha_mode = AlphaMode::Blend; + Ok(()) + } + MaterialMut::Particles(_) => Err(ProcessingError::InvalidArgument( + "custom blend is not yet configurable on particle materials".to_string(), + )), + MaterialMut::Custom(mat) => { + mat.blend_state = Some(blend); + mat.alpha_mode = AlphaMode::Blend; + Ok(()) + } + }, + ) +} + pub fn destroy( In(entity): In, mut commands: Commands, @@ -127,18 +386,21 @@ pub fn destroy( #[bind_group_data(ProcessingMaterialKey)] pub struct ProcessingMaterial { pub blend_state: Option, + pub depth_write: Option, } #[repr(C)] #[derive(Eq, PartialEq, Hash, Copy, Clone)] pub struct ProcessingMaterialKey { blend_state: Option, + depth_write: Option, } impl From<&ProcessingMaterial> for ProcessingMaterialKey { fn from(mat: &ProcessingMaterial) -> Self { ProcessingMaterialKey { blend_state: mat.blend_state, + depth_write: mat.depth_write, } } } @@ -158,16 +420,30 @@ impl MaterialExtension for ProcessingMaterial { _layout: &MeshVertexBufferLayoutRef, key: MaterialExtensionKey, ) -> std::result::Result<(), SpecializedMeshPipelineError> { - if let Some(blend_state) = key.bind_group_data.blend_state { - // this should never be null but we have to check it anyway - if let Some(fragment_state) = &mut descriptor.fragment { - fragment_state.targets.iter_mut().for_each(|target| { - if let Some(target) = target { - target.blend = Some(blend_state); - } - }); - } - } + apply_pipeline_state( + descriptor, + key.bind_group_data.blend_state, + key.bind_group_data.depth_write, + ); Ok(()) } } + +pub(crate) fn apply_pipeline_state( + descriptor: &mut RenderPipelineDescriptor, + blend_state: Option, + depth_write: Option, +) { + if let Some(blend_state) = blend_state + && let Some(fragment_state) = &mut descriptor.fragment + { + for target in fragment_state.targets.iter_mut().flatten() { + target.blend = Some(blend_state); + } + } + if let Some(depth_write) = depth_write + && let Some(depth_stencil) = &mut descriptor.depth_stencil + { + depth_stencil.depth_write_enabled = Some(depth_write); + } +} diff --git a/crates/processing_render/src/material/pbr.rs b/crates/processing_render/src/material/pbr.rs index df3df238..f4fd86f8 100644 --- a/crates/processing_render/src/material/pbr.rs +++ b/crates/processing_render/src/material/pbr.rs @@ -1,17 +1,17 @@ use bevy::prelude::*; -use super::MaterialValue; +use crate::shader_value::ShaderValue; use processing_core::error::{ProcessingError, Result}; -/// Set a property on a StandardMaterial by name. pub fn set_property( material: &mut StandardMaterial, name: &str, - value: &MaterialValue, + value: &ShaderValue, + texture_handle: Option>, ) -> Result<()> { match name { "base_color" | "color" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); @@ -19,7 +19,7 @@ pub fn set_property( material.base_color = Color::srgba(c[0], c[1], c[2], c[3]); } "metallic" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -27,7 +27,7 @@ pub fn set_property( material.metallic = *v; } "roughness" | "perceptual_roughness" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -35,7 +35,7 @@ pub fn set_property( material.perceptual_roughness = *v; } "reflectance" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -43,52 +43,23 @@ pub fn set_property( material.reflectance = *v; } "emissive" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); }; material.emissive = LinearRgba::new(c[0], c[1], c[2], c[3]); } - "unlit" => { - let MaterialValue::Float(v) = value else { + "base_color_texture" | "texture" => { + let Some(handle) = texture_handle else { return Err(ProcessingError::InvalidArgument(format!( - "'{name}' expects Float, got {value:?}" - ))); - }; - material.unlit = *v > 0.5; - } - "double_sided" => { - let MaterialValue::Float(v) = value else { - return Err(ProcessingError::InvalidArgument(format!( - "'{name}' expects Float, got {value:?}" + "'{name}' expects Texture, got {value:?}" ))); }; - material.double_sided = *v > 0.5; - } - "alpha_mode" => { - let MaterialValue::Int(v) = value else { - return Err(ProcessingError::InvalidArgument(format!( - "'{name}' expects Int, got {value:?}" - ))); - }; - material.alpha_mode = match v { - 0 => AlphaMode::Opaque, - // TODO: allow configuring the alpha cutoff value - 1 => AlphaMode::Mask(0.5), - 2 => AlphaMode::Blend, - 3 => AlphaMode::Premultiplied, - 4 => AlphaMode::Add, - 5 => AlphaMode::Multiply, - _ => { - return Err(ProcessingError::InvalidArgument(format!( - "unknown alpha_mode value: {v}" - ))); - } - }; + material.base_color_texture = Some(handle); } _ => { - return Err(ProcessingError::UnknownMaterialProperty(name.to_string())); + return Err(ProcessingError::UnknownShaderProperty(name.to_string())); } } Ok(()) diff --git a/crates/processing_render/src/particles/algebra.rs b/crates/processing_render/src/particles/algebra.rs new file mode 100644 index 00000000..7500040b --- /dev/null +++ b/crates/processing_render/src/particles/algebra.rs @@ -0,0 +1,613 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::{ProcessingError, Result}; + +use crate::shader_value::ShaderValue; +use crate::{buffer_size, compute_dispatch, compute_set, shader_create_with_features}; + +const WG: u32 = 64; + +pub const MAP_AFFINE: u32 = 0; +pub const MAP_ABS: u32 = 1; +pub const MAP_NEGATE: u32 = 2; +pub const MAP_CLAMP: u32 = 3; +pub const MAP_FLOOR: u32 = 4; +pub const MAP_SQUARE: u32 = 5; +pub const MAP_SQRT: u32 = 6; +pub const MAP_GREATER: u32 = 7; +pub const MAP_LESS: u32 = 8; +pub const MAP_GEQ: u32 = 9; +pub const MAP_LEQ: u32 = 10; +pub const MAP_EQ: u32 = 11; +pub const MAP_NEQ: u32 = 12; + +const MAP_SRC: &str = r#" +struct Params { + components: u32, + op: u32, + p0: f32, + p1: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +fn apply_op(x: f32) -> f32 { + switch params.op { + case 0u: { return x * params.p0 + params.p1; } + case 1u: { return abs(x); } + case 2u: { return -x; } + case 3u: { return clamp(x, params.p0, params.p1); } + case 4u: { return floor(x); } + case 5u: { return x * x; } + case 6u: { return sqrt(max(x, 0.0)); } + case 7u: { return select(0.0, 1.0, x > params.p0); } + case 8u: { return select(0.0, 1.0, x < params.p0); } + case 9u: { return select(0.0, 1.0, x >= params.p0); } + case 10u: { return select(0.0, 1.0, x <= params.p0); } + case 11u: { return select(0.0, 1.0, abs(x - params.p0) <= params.p1); } + case 12u: { return select(0.0, 1.0, abs(x - params.p0) > params.p1); } + default: { return x; } + } +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + @if(in_place) { a[idx] = apply_op(a[idx]); } + @if(!in_place) { dst[idx] = apply_op(a[idx]); } + } +} +"#; + +struct Variants { + out_of_place: Entity, + in_place: Entity, +} + +fn variants(cache: &Mutex>, src: &str) -> Result { + let mut guard = cache.lock().unwrap(); + if let Some((o, i)) = *guard { + return Ok(Variants { + out_of_place: o, + in_place: i, + }); + } + let out_of_place = shader_create_with_features(src, &[("in_place", false)])?; + let in_place = shader_create_with_features(src, &[("in_place", true)])?; + let out_of_place = crate::compute_create(out_of_place)?; + let in_place = crate::compute_create(in_place)?; + *guard = Some((out_of_place, in_place)); + Ok(Variants { + out_of_place, + in_place, + }) +} + +static MAP: Mutex> = Mutex::new(None); + +fn dispatch_particles(compute: Entity, floats: u64, components: u32) -> Result<()> { + let n = (floats / components as u64) as u32; + compute_dispatch(compute, n.div_ceil(WG), 1, 1) +} + +fn check_components(verb: &str, components: u32) -> Result<()> { + if components == 0 || components > 4 { + return Err(ProcessingError::InvalidArgument(format!( + "{verb}: components must be 1..=4, got {components}" + ))); + } + Ok(()) +} + +fn ensure_no_alias(verb: &str, rw: Entity, reads: &[Entity]) -> Result<()> { + if reads.contains(&rw) { + return Err(ProcessingError::InvalidArgument(format!( + "{verb}: dst aliases a read-only operand; make dst the first operand \ + (in-place) or pass a distinct dst buffer" + ))); + } + Ok(()) +} + +pub fn map(dst: Entity, a: Entity, components: u32, op: u32, p0: f32, p1: f32) -> Result<()> { + check_components("map", components)?; + let v = variants(&MAP, MAP_SRC)?; + let floats = buffer_size(a)? / 4; + + if dst == a { + let c = v.in_place; + compute_set(c, "a", ShaderValue::Buffer(a))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "p0", ShaderValue::Float(p0))?; + compute_set(c, "p1", ShaderValue::Float(p1))?; + dispatch_particles(c, floats, components) + } else { + let c = v.out_of_place; + compute_set(c, "a", ShaderValue::Buffer(a))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "p0", ShaderValue::Float(p0))?; + compute_set(c, "p1", ShaderValue::Float(p1))?; + dispatch_particles(c, floats, components) + } +} + +const COMBINE_SRC: &str = r#" +struct Params { + components: u32, + op: u32, + b_scale: f32, + b_offset: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@group(0) @binding(1) var b: array; +@if(!in_place) @group(0) @binding(2) var dst: array; +@group(0) @binding(3) var params: Params; + +fn combine_op(x: f32, y: f32) -> f32 { + switch params.op { + case 0u: { return x + y; } + case 1u: { return x - y; } + case 2u: { return x * y; } + case 3u: { if y == 0.0 { return x; } return x / y; } + case 4u: { return min(x, y); } + case 5u: { return max(x, y); } + case 6u: { return pow(max(x, 0.0), y); } + default: { return x; } + } +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + let r = combine_op(a[idx], b[idx] * params.b_scale + params.b_offset); + @if(in_place) { a[idx] = r; } + @if(!in_place) { dst[idx] = r; } + } +} +"#; + +static COMBINE: Mutex> = Mutex::new(None); + +pub fn combine( + dst: Entity, + a: Entity, + b: Entity, + components: u32, + op: u32, + b_scale: f32, + b_offset: f32, +) -> Result<()> { + check_components("combine", components)?; + let v = variants(&COMBINE, COMBINE_SRC)?; + let floats = buffer_size(a)? / 4; + + let c = if dst == a { + ensure_no_alias("combine", a, &[b])?; + v.in_place + } else { + ensure_no_alias("combine", dst, &[a, b])?; + compute_set(v.out_of_place, "a", ShaderValue::Buffer(a))?; + compute_set(v.out_of_place, "dst", ShaderValue::Buffer(dst))?; + v.out_of_place + }; + if dst == a { + compute_set(c, "a", ShaderValue::Buffer(a))?; + } + compute_set(c, "b", ShaderValue::Buffer(b))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + compute_set(c, "b_scale", ShaderValue::Float(b_scale))?; + compute_set(c, "b_offset", ShaderValue::Float(b_offset))?; + dispatch_particles(c, floats, components) +} + +const MIX_SRC: &str = r#" +struct Params { + components: u32, + t_clamp: u32, + t_scale: f32, + t_offset: f32, +} + +@if(in_place) @group(0) @binding(0) var a: array; +@if(!in_place) @group(0) @binding(0) var a: array; +@group(0) @binding(1) var b: array; +@group(0) @binding(2) var t: array; +@if(!in_place) @group(0) @binding(3) var dst: array; +@group(0) @binding(4) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&a) / params.components; + if i >= n { return; } + // t is one value per particle, broadcast across the components. + var tv = t[i] * params.t_scale + params.t_offset; + if params.t_clamp != 0u { tv = clamp(tv, 0.0, 1.0); } + for (var c = 0u; c < params.components; c = c + 1u) { + let idx = i * params.components + c; + let r = mix(a[idx], b[idx], tv); + @if(in_place) { a[idx] = r; } + @if(!in_place) { dst[idx] = r; } + } +} +"#; + +static MIX: Mutex> = Mutex::new(None); + +pub fn mix( + dst: Entity, + a: Entity, + b: Entity, + t: Entity, + components: u32, + t_scale: f32, + t_offset: f32, + t_clamp: bool, +) -> Result<()> { + check_components("mix", components)?; + let v = variants(&MIX, MIX_SRC)?; + let floats = buffer_size(a)? / 4; + + let c = if dst == a { + ensure_no_alias("mix", a, &[b, t])?; + v.in_place + } else { + ensure_no_alias("mix", dst, &[a, b, t])?; + compute_set(v.out_of_place, "a", ShaderValue::Buffer(a))?; + compute_set(v.out_of_place, "dst", ShaderValue::Buffer(dst))?; + v.out_of_place + }; + if dst == a { + compute_set(c, "a", ShaderValue::Buffer(a))?; + } + compute_set(c, "b", ShaderValue::Buffer(b))?; + compute_set(c, "t", ShaderValue::Buffer(t))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "t_scale", ShaderValue::Float(t_scale))?; + compute_set(c, "t_offset", ShaderValue::Float(t_offset))?; + compute_set(c, "t_clamp", ShaderValue::UInt(t_clamp as u32))?; + dispatch_particles(c, floats, components) +} + +const LOOKUP_SRC: &str = r#" +struct Params { + in_components: u32, + u_scale: f32, + u_offset: f32, + v_scale: f32, + v_offset: f32, + color_scale: f32, + _p0: u32, + _p1: u32, +} + +@group(0) @binding(0) var op_in: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; +@group(0) @binding(3) var tex: texture_2d; +@group(0) @binding(4) var samp: sampler; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst) / 4u; + if i >= n { return; } + + var uv: vec2; + if params.in_components == 1u { + uv = vec2(op_in[i] * params.u_scale + params.u_offset, 0.5); + } else { + uv = vec2( + op_in[i * 2u] * params.u_scale + params.u_offset, + op_in[i * 2u + 1u] * params.v_scale + params.v_offset, + ); + } + uv = clamp(uv, vec2(0.0), vec2(1.0)); + + let c = textureSampleLevel(tex, samp, uv, 0.0); + dst[i * 4u + 0u] = c.r * params.color_scale; + dst[i * 4u + 1u] = c.g * params.color_scale; + dst[i * 4u + 2u] = c.b * params.color_scale; + dst[i * 4u + 3u] = c.a; +} +"#; + +static LOOKUP: Mutex> = Mutex::new(None); + +fn single_pipeline(cache: &Mutex>, src: &str) -> Result { + let mut guard = cache.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = crate::compute_create(crate::shader_create(src)?)?; + *guard = Some(compute); + Ok(compute) +} + +#[allow(clippy::too_many_arguments)] +pub fn lookup( + dst: Entity, + op_in: Entity, + tex: Entity, + in_components: u32, + u_scale: f32, + u_offset: f32, + v_scale: f32, + v_offset: f32, + color_scale: f32, +) -> Result<()> { + if in_components != 1 && in_components != 2 { + return Err(ProcessingError::InvalidArgument(format!( + "lookup: in_components must be 1 or 2, got {in_components}" + ))); + } + ensure_no_alias("lookup", dst, &[op_in])?; + let c = single_pipeline(&LOOKUP, LOOKUP_SRC)?; + compute_set(c, "op_in", ShaderValue::Buffer(op_in))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "in_components", ShaderValue::UInt(in_components))?; + compute_set(c, "u_scale", ShaderValue::Float(u_scale))?; + compute_set(c, "u_offset", ShaderValue::Float(u_offset))?; + compute_set(c, "v_scale", ShaderValue::Float(v_scale))?; + compute_set(c, "v_offset", ShaderValue::Float(v_offset))?; + compute_set(c, "color_scale", ShaderValue::Float(color_scale))?; + compute_set(c, "tex", ShaderValue::Texture(tex))?; + compute_set(c, "samp", ShaderValue::Texture(tex))?; + let n = (buffer_size(dst)? / 16) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +pub const REDUCE_LENGTH: u32 = 0; +pub const REDUCE_SUM: u32 = 1; +pub const REDUCE_MIN: u32 = 2; +pub const REDUCE_MAX: u32 = 3; +pub const REDUCE_SUMSQ: u32 = 4; +pub const REDUCE_MEAN: u32 = 5; + +const REDUCE_SRC: &str = r#" +struct Params { components: u32, op: u32 } + +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst); + if i >= n { return; } + let base = i * params.components; + var s = 0.0; + var ss = 0.0; + var mn = src[base]; + var mx = src[base]; + for (var c = 0u; c < params.components; c = c + 1u) { + let v = src[base + c]; + s = s + v; + ss = ss + v * v; + mn = min(mn, v); + mx = max(mx, v); + } + switch params.op { + case 0u: { dst[i] = sqrt(ss); } + case 1u: { dst[i] = s; } + case 2u: { dst[i] = mn; } + case 3u: { dst[i] = mx; } + case 4u: { dst[i] = ss; } + case 5u: { dst[i] = s / f32(params.components); } + default: { dst[i] = s; } + } +} +"#; + +static REDUCE: Mutex> = Mutex::new(None); + +pub fn reduce_components(dst: Entity, src: Entity, components: u32, op: u32) -> Result<()> { + check_components("reduce_components", components)?; + ensure_no_alias("reduce_components", dst, &[src])?; + let c = single_pipeline(&REDUCE, REDUCE_SRC)?; + compute_set(c, "src", ShaderValue::Buffer(src))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "op", ShaderValue::UInt(op))?; + let n = (buffer_size(dst)? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +const EXTRACT_SRC: &str = r#" +struct Params { components: u32, index: u32 } + +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst); + if i >= n { return; } + dst[i] = src[i * params.components + params.index]; +} +"#; + +static EXTRACT: Mutex> = Mutex::new(None); + +pub fn extract(dst: Entity, src: Entity, components: u32, index: u32) -> Result<()> { + check_components("extract", components)?; + if index >= components { + return Err(ProcessingError::InvalidArgument(format!( + "extract: index {index} out of range for {components} components" + ))); + } + ensure_no_alias("extract", dst, &[src])?; + let c = single_pipeline(&EXTRACT, EXTRACT_SRC)?; + compute_set(c, "src", ShaderValue::Buffer(src))?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "index", ShaderValue::UInt(index))?; + let n = (buffer_size(dst)? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +const PACK_SRC: &str = r#" +struct Params { components: u32 } + +@group(0) @binding(0) var s0: array; +@group(0) @binding(1) var s1: array; +@if(ge3) @group(0) @binding(2) var s2: array; +@if(ge4) @group(0) @binding(3) var s3: array; +@group(0) @binding(4) var dst: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&s0); + if i >= n { return; } + let c = params.components; + dst[i * c + 0u] = s0[i]; + dst[i * c + 1u] = s1[i]; + @if(ge3) { dst[i * c + 2u] = s2[i]; } + @if(ge4) { dst[i * c + 3u] = s3[i]; } +} +"#; + +static PACK: Mutex> = Mutex::new(None); + +fn pack_pipeline(components: u32) -> Result { + let mut guard = PACK.lock().unwrap(); + if guard.is_none() { + let p2 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", false), ("ge4", false)], + )?)?; + let p3 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", true), ("ge4", false)], + )?)?; + let p4 = crate::compute_create(shader_create_with_features( + PACK_SRC, + &[("ge3", true), ("ge4", true)], + )?)?; + *guard = Some([p2, p3, p4]); + } + Ok(guard.unwrap()[(components - 2) as usize]) +} + +pub fn pack(dst: Entity, sources: &[Entity]) -> Result<()> { + let components = sources.len() as u32; + if !(2..=4).contains(&components) { + return Err(ProcessingError::InvalidArgument(format!( + "pack: expects 2..=4 source buffers, got {}", + sources.len() + ))); + } + ensure_no_alias("pack", dst, sources)?; + let c = pack_pipeline(components)?; + compute_set(c, "s0", ShaderValue::Buffer(sources[0]))?; + compute_set(c, "s1", ShaderValue::Buffer(sources[1]))?; + if components >= 3 { + compute_set(c, "s2", ShaderValue::Buffer(sources[2]))?; + } + if components >= 4 { + compute_set(c, "s3", ShaderValue::Buffer(sources[3]))?; + } + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + let n = (buffer_size(sources[0])? / 4) as u32; + compute_dispatch(c, n.div_ceil(WG), 1, 1) +} + +pub const GEN_UNIFORM: u32 = 0; +pub const GEN_SIGNED: u32 = 1; +pub const GEN_GAUSSIAN: u32 = 2; + +const GENERATE_SRC: &str = r#" +struct Params { + components: u32, + mode: u32, + seed: u32, + scale: f32, + offset: f32, +} + +@group(0) @binding(0) var dst: array; +@group(0) @binding(1) var params: Params; + +fn hashu(x0: u32) -> u32 { + var x = x0; + x = x ^ (x >> 16u); + x = x * 0x7feb352du; + x = x ^ (x >> 15u); + x = x * 0x846ca68bu; + x = x ^ (x >> 16u); + return x; +} + +fn rnd(x: u32) -> f32 { + return f32(hashu(x)) / 4294967295.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&dst) / params.components; + if i >= n { return; } + for (var c = 0u; c < params.components; c = c + 1u) { + let id = i * params.components + c; + let base = id ^ (params.seed * 0x9e3779b9u); + var v: f32; + switch params.mode { + case 1u: { v = rnd(base) * 2.0 - 1.0; } + case 2u: { + let u1 = max(rnd(base), 1e-7); + let u2 = rnd(base ^ 0x85ebca6bu); + v = sqrt(-2.0 * log(u1)) * cos(6.28318530718 * u2); + } + default: { v = rnd(base); } + } + dst[id] = v * params.scale + params.offset; + } +} +"#; + +static GENERATE: Mutex> = Mutex::new(None); + +pub fn generate( + dst: Entity, + components: u32, + mode: u32, + seed: u32, + scale: f32, + offset: f32, +) -> Result<()> { + check_components("generate", components)?; + let c = single_pipeline(&GENERATE, GENERATE_SRC)?; + compute_set(c, "dst", ShaderValue::Buffer(dst))?; + compute_set(c, "components", ShaderValue::UInt(components))?; + compute_set(c, "mode", ShaderValue::UInt(mode))?; + compute_set(c, "seed", ShaderValue::UInt(seed))?; + compute_set(c, "scale", ShaderValue::Float(scale))?; + compute_set(c, "offset", ShaderValue::Float(offset))?; + let floats = buffer_size(dst)? / 4; + dispatch_particles(c, floats, components) +} diff --git a/crates/processing_render/src/particles/compact.rs b/crates/processing_render/src/particles/compact.rs new file mode 100644 index 00000000..ba016798 --- /dev/null +++ b/crates/processing_render/src/particles/compact.rs @@ -0,0 +1,66 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::particles::scan::prefix_sum_u32; +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_read_element, buffer_size, compute_create, + compute_dispatch, compute_set, shader_load, +}; + +const WG: u32 = 64; +const FLAG_SHADER: &str = "embedded://processing_render/particles/kernels/compact_flag.wgsl"; +const SCATTER_SHADER: &str = "embedded://processing_render/particles/kernels/compact_scatter.wgsl"; + +static COMPUTES: Mutex> = Mutex::new(None); +static SCANNED: Mutex> = Mutex::new(None); + +fn computes() -> Result<(Entity, Entity)> { + let mut guard = COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let flag = compute_create(shader_load(FLAG_SHADER)?)?; + let scatter = compute_create(shader_load(SCATTER_SHADER)?)?; + *guard = Some((flag, scatter)); + Ok((flag, scatter)) +} + +fn scanned_buffer(bytes: u64) -> Result { + let mut guard = SCANNED.lock().unwrap(); + if let Some((entity, size)) = *guard { + if size == bytes { + return Ok(entity); + } + let _ = buffer_destroy(entity); + } + let entity = buffer_create(bytes)?; + *guard = Some((entity, bytes)); + Ok(entity) +} + +pub fn compact(flags: Entity, indices_out: Entity) -> Result { + let n = (buffer_size(flags)? / 4) as u32; + if n == 0 { + return Ok(0); + } + let (flag_c, scatter_c) = computes()?; + let scanned = scanned_buffer(((n + 1) as u64) * 4)?; + + compute_set(flag_c, "flags", ShaderValue::Buffer(flags))?; + compute_set(flag_c, "scanned", ShaderValue::Buffer(scanned))?; + compute_dispatch(flag_c, (n + 1).div_ceil(WG), 1, 1)?; + + prefix_sum_u32(scanned)?; + + compute_set(scatter_c, "flags", ShaderValue::Buffer(flags))?; + compute_set(scatter_c, "scanned", ShaderValue::Buffer(scanned))?; + compute_set(scatter_c, "indices", ShaderValue::Buffer(indices_out))?; + compute_dispatch(scatter_c, n.div_ceil(WG), 1, 1)?; + + let bytes = buffer_read_element(scanned, (n as u64) * 4, 4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} diff --git a/crates/processing_render/src/particles/emit.rs b/crates/processing_render/src/particles/emit.rs new file mode 100644 index 00000000..f31fb5aa --- /dev/null +++ b/crates/processing_render/src/particles/emit.rs @@ -0,0 +1,275 @@ +use std::sync::Mutex; + +use bevy::prelude::*; + +use processing_core::app_mut; +use processing_core::error; + +use crate::geometry; +use crate::particles::grid::{Grid, grid_bind, grid_build}; +use crate::particles::kernels::KernelRequires; +use crate::particles::{Particles, particles_ensure_attribute}; +use crate::shader_value::ShaderValue; +use crate::{buffer_write_element, compute_create, compute_dispatch, compute_set, shader_load}; + +const WORKGROUP_SIZE: u32 = 64; + +pub fn particles_emit_gpu( + particles_entity: Entity, + count: u32, + compute_entity: Entity, +) -> error::Result<()> { + if count == 0 { + return Ok(()); + } + + let (capacity, head, buffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + if count > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "particles_emit_gpu count={} exceeds field capacity {}", + count, field.capacity + ))); + } + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + buffers.push((attr.name.to_string(), buf_entity)); + } + Ok((field.capacity, field.emit_head, buffers)) + })?; + + for (name, buf_entity) in buffers { + match compute_set(compute_entity, name, ShaderValue::Buffer(buf_entity)) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + for (name, value) in [ + ("emit_base", ShaderValue::UInt(head)), + ("emit_count", ShaderValue::UInt(count)), + ("emit_capacity", ShaderValue::UInt(capacity)), + ] { + match compute_set(compute_entity, name, value) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + let workgroup_count = count.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1)?; + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field.emit_head = (field.emit_head + count) % field.capacity; + Ok(()) + }) +} + +pub fn particles_emit( + particles_entity: Entity, + n: u32, + attribute_data: Vec<(Entity, Vec)>, +) -> error::Result<()> { + if n == 0 { + return Ok(()); + } + + let (capacity, head, attr_specs) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + if n > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "particles_emit n={} exceeds field capacity {}", + n, field.capacity + ))); + } + let mut specs: Vec<(Entity, u32, Entity)> = Vec::with_capacity(attribute_data.len()); + for (attr_entity, _) in &attribute_data { + let attr = world + .get::(*attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + let buf = field.buffer(*attr_entity).ok_or_else(|| { + error::ProcessingError::InvalidArgument(format!( + "particles have no buffer for attribute {:?}", + attr_entity + )) + })?; + specs.push((*attr_entity, attr.format.byte_size() as u32, buf)); + } + Ok((field.capacity, field.emit_head, specs)) + })?; + + for ((_, bytes), &(_, byte_size, buf)) in attribute_data.iter().zip(attr_specs.iter()) { + let expected = (n as usize) * (byte_size as usize); + if bytes.len() != expected { + return Err(error::ProcessingError::InvalidArgument(format!( + "expected {} bytes ({} particles * {} bytes), got {}", + expected, + n, + byte_size, + bytes.len() + ))); + } + let first_chunk_n = (capacity - head).min(n); + let split = (first_chunk_n as usize) * (byte_size as usize); + let first_offset = (head as u64) * (byte_size as u64); + buffer_write_element(buf, first_offset, bytes[..split].to_vec())?; + if first_chunk_n < n { + buffer_write_element(buf, 0, bytes[split..].to_vec())?; + } + } + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field.emit_head = (field.emit_head + n) % field.capacity; + Ok(()) + }) +} + +pub fn particles_flock( + particles_entity: Entity, + flock_entity: Entity, + grid: &Grid, +) -> error::Result<()> { + let position = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + if attr.name == "position" { + return Ok(buf_entity); + } + } + Err(error::ProcessingError::InvalidArgument( + "particles_flock requires a `position` attribute".to_string(), + )) + })?; + + grid_build(grid, position)?; + grid_bind(grid, flock_entity)?; + particles_apply(particles_entity, flock_entity) +} + +static NEIGHBOR_COMPUTE: Mutex> = Mutex::new(None); + +fn neighbor_compute() -> error::Result { + let mut guard = NEIGHBOR_COMPUTE.lock().unwrap(); + if let Some(entity) = *guard { + return Ok(entity); + } + let shader = shader_load("embedded://processing_render/particles/kernels/neighbor.wgsl")?; + let entity = compute_create(shader)?; + *guard = Some(entity); + Ok(entity) +} + +pub fn particles_gather( + particles_entity: Entity, + grid: &Grid, + source: Entity, + out: Entity, + op: u32, + radius: f32, + falloff_mode: u32, + components: u32, +) -> error::Result<()> { + let (position, _capacity) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + if attr.name == "position" { + return Ok((buf_entity, field.capacity)); + } + } + Err(error::ProcessingError::InvalidArgument( + "apply(neighbor) requires a `position` attribute".to_string(), + )) + })?; + + if out == source || out == position { + return Err(error::ProcessingError::InvalidArgument( + "apply(neighbor): `out` must be a distinct buffer from the source and \ + position (in-place gather is a read/write hazard)" + .to_string(), + )); + } + + grid_build(grid, position)?; + let neighbor = neighbor_compute()?; + grid_bind(grid, neighbor)?; + compute_set(neighbor, "position", ShaderValue::Buffer(position))?; + compute_set(neighbor, "source", ShaderValue::Buffer(source))?; + compute_set(neighbor, "out", ShaderValue::Buffer(out))?; + compute_set(neighbor, "radius", ShaderValue::Float(radius))?; + compute_set(neighbor, "op", ShaderValue::UInt(op))?; + compute_set(neighbor, "falloff_mode", ShaderValue::UInt(falloff_mode))?; + compute_set(neighbor, "components", ShaderValue::UInt(components))?; + + let capacity = _capacity; + compute_dispatch(neighbor, capacity.div_ceil(WORKGROUP_SIZE), 1, 1) +} + +pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { + let required: Vec = app_mut(|app| { + Ok(app + .world() + .get::(compute_entity) + .map(|r| r.0.clone()) + .unwrap_or_default()) + })?; + for attr_entity in required { + particles_ensure_attribute(particles_entity, attr_entity)?; + } + + let (capacity, buffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + buffers.push((attr.name.to_string(), buf_entity)); + } + Ok((field.capacity, buffers)) + })?; + + for (name, buf_entity) in buffers { + match compute_set(compute_entity, name, ShaderValue::Buffer(buf_entity)) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + let workgroup_count = capacity.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1) +} diff --git a/crates/processing_render/src/particles/grid.rs b/crates/processing_render/src/particles/grid.rs new file mode 100644 index 00000000..0a31f069 --- /dev/null +++ b/crates/processing_render/src/particles/grid.rs @@ -0,0 +1,112 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::particles::scan::prefix_sum_u32; +use crate::shader_value::ShaderValue; +use crate::{buffer_create, compute_create, compute_dispatch, compute_set, shader_load}; + +const CLEAR_SHADER: &str = "embedded://processing_render/particles/kernels/grid_clear.wgsl"; +const COUNT_SHADER: &str = "embedded://processing_render/particles/kernels/grid_count.wgsl"; +const COPY_SHADER: &str = "embedded://processing_render/particles/kernels/grid_copy.wgsl"; +const SCATTER_SHADER: &str = "embedded://processing_render/particles/kernels/grid_scatter.wgsl"; + +static GRID_COMPUTES: Mutex> = Mutex::new(None); + +fn grid_computes() -> Result<(Entity, Entity, Entity, Entity)> { + let mut guard = GRID_COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let clear = compute_create(shader_load(CLEAR_SHADER)?)?; + let count = compute_create(shader_load(COUNT_SHADER)?)?; + let copy = compute_create(shader_load(COPY_SHADER)?)?; + let scatter = compute_create(shader_load(SCATTER_SHADER)?)?; + *guard = Some((clear, count, copy, scatter)); + Ok((clear, count, copy, scatter)) +} + +#[derive(Clone, Copy, Debug)] +pub struct GridParams { + pub min: [f32; 3], + pub cell_size: f32, + pub dims: [u32; 3], +} + +impl GridParams { + pub fn num_cells(&self) -> u32 { + self.dims[0] * self.dims[1] * self.dims[2] + } +} + +#[derive(Clone, Copy)] +pub struct Grid { + pub offsets: Entity, + pub cursor: Entity, + pub sorted: Entity, + pub params: GridParams, + pub capacity: u32, +} + +const CLEAR_WG: u32 = 256; +const PARTICLE_WG: u32 = 64; +const COPY_WG: u32 = 256; + +pub fn grid_create(params: GridParams, capacity: u32) -> Result { + let num_cells = params.num_cells(); + let offsets = buffer_create(((num_cells + 1) as u64) * 4)?; + let cursor = buffer_create((num_cells as u64) * 4)?; + let sorted = buffer_create((capacity.max(1) as u64) * 4)?; + Ok(Grid { + offsets, + cursor, + sorted, + params, + capacity, + }) +} + +pub fn grid_bind(grid: &Grid, compute: Entity) -> Result<()> { + compute_set(compute, "offsets", ShaderValue::Buffer(grid.offsets))?; + compute_set(compute, "sorted", ShaderValue::Buffer(grid.sorted))?; + set_domain(compute, &grid.params)?; + Ok(()) +} + +fn set_domain(compute: Entity, params: &GridParams) -> Result<()> { + compute_set(compute, "grid_min", ShaderValue::Float3(params.min))?; + compute_set(compute, "cell_size", ShaderValue::Float(params.cell_size))?; + compute_set(compute, "dims_x", ShaderValue::UInt(params.dims[0]))?; + compute_set(compute, "dims_y", ShaderValue::UInt(params.dims[1]))?; + compute_set(compute, "dims_z", ShaderValue::UInt(params.dims[2]))?; + Ok(()) +} + +pub fn grid_build(grid: &Grid, position: Entity) -> Result<()> { + let (clear, count, copy, scatter) = grid_computes()?; + let num_cells = grid.params.num_cells(); + + compute_set(clear, "counts", ShaderValue::Buffer(grid.offsets))?; + compute_dispatch(clear, (num_cells + 1).div_ceil(CLEAR_WG), 1, 1)?; + + compute_set(count, "position", ShaderValue::Buffer(position))?; + compute_set(count, "counts", ShaderValue::Buffer(grid.offsets))?; + set_domain(count, &grid.params)?; + compute_dispatch(count, grid.capacity.div_ceil(PARTICLE_WG), 1, 1)?; + + prefix_sum_u32(grid.offsets)?; + + compute_set(copy, "starts", ShaderValue::Buffer(grid.offsets))?; + compute_set(copy, "cursor", ShaderValue::Buffer(grid.cursor))?; + compute_dispatch(copy, num_cells.div_ceil(COPY_WG), 1, 1)?; + + compute_set(scatter, "position", ShaderValue::Buffer(position))?; + compute_set(scatter, "cursor", ShaderValue::Buffer(grid.cursor))?; + compute_set(scatter, "sorted", ShaderValue::Buffer(grid.sorted))?; + set_domain(scatter, &grid.params)?; + compute_dispatch(scatter, grid.capacity.div_ceil(PARTICLE_WG), 1, 1)?; + + Ok(()) +} diff --git a/crates/processing_render/src/particles/kernels/age.wgsl b/crates/processing_render/src/particles/kernels/age.wgsl new file mode 100644 index 00000000..65cea10f --- /dev/null +++ b/crates/processing_render/src/particles/kernels/age.wgsl @@ -0,0 +1,20 @@ +struct Params { + dt: f32, +} + +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var life: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + + if life[i] <= 0.0 { return; } + age[i] = age[i] + params.dt; + if age[i] >= life[i] { + life[i] = 0.0; + } +} diff --git a/crates/processing_render/src/particles/kernels/attr_combine.wgsl b/crates/processing_render/src/particles/kernels/attr_combine.wgsl new file mode 100644 index 00000000..826d0e7c --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attr_combine.wgsl @@ -0,0 +1,34 @@ +struct Params { + op: u32, + b_scale: f32, + b_offset: f32, +} + +@group(0) @binding(0) var op_a: array; +@group(0) @binding(1) var op_b: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&op_a); + if i >= count { return; } + + let a = op_a[i]; + let b = op_b[i] * params.b_scale + params.b_offset; + + var r: f32; + switch params.op { + case 0u: { r = a + b; } + case 1u: { r = a - b; } + case 2u: { r = a * b; } + case 3u: { + if b == 0.0 { r = a; } else { r = a / b; } + } + case 4u: { r = min(a, b); } + case 5u: { r = max(a, b); } + case 6u: { r = pow(max(a, 0.0), b); } + default: { r = a; } + } + op_a[i] = r; +} diff --git a/crates/processing_render/src/particles/kernels/attr_linear.wgsl b/crates/processing_render/src/particles/kernels/attr_linear.wgsl new file mode 100644 index 00000000..ed931511 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attr_linear.wgsl @@ -0,0 +1,15 @@ +struct Params { + scale: f32, + offset: f32, +} + +@group(0) @binding(0) var op: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&op); + if i >= count { return; } + op[i] = op[i] * params.scale + params.offset; +} diff --git a/crates/processing_render/src/particles/kernels/attr_lookup1d.wgsl b/crates/processing_render/src/particles/kernels/attr_lookup1d.wgsl new file mode 100644 index 00000000..b1551c46 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attr_lookup1d.wgsl @@ -0,0 +1,27 @@ +struct Params { + scale: f32, + offset: f32, +} + +@group(0) @binding(0) var op_in: array; +@group(0) @binding(1) var op_out_r: array; +@group(0) @binding(2) var op_out_g: array; +@group(0) @binding(3) var op_out_b: array; +@group(0) @binding(4) var op_out_a: array; +@group(0) @binding(5) var params: Params; +@group(0) @binding(6) var ramp: texture_2d; +@group(0) @binding(7) var ramp_sampler: sampler; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&op_in); + if i >= count { return; } + + let t = clamp(op_in[i] * params.scale + params.offset, 0.0, 1.0); + let c = textureSampleLevel(ramp, ramp_sampler, vec2(t, 0.5), 0.0); + op_out_r[i] = c.r; + op_out_g[i] = c.g; + op_out_b[i] = c.b; + op_out_a[i] = c.a; +} diff --git a/crates/processing_render/src/particles/kernels/attr_lookup2d.wgsl b/crates/processing_render/src/particles/kernels/attr_lookup2d.wgsl new file mode 100644 index 00000000..f5a61d0d --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attr_lookup2d.wgsl @@ -0,0 +1,26 @@ +struct Params { + u_scale: f32, + u_offset: f32, + v_scale: f32, + v_offset: f32, + color_scale: f32, +} + +@group(0) @binding(0) var op_in_u: array; +@group(0) @binding(1) var op_in_v: array; +@group(0) @binding(2) var op_out: array>; +@group(0) @binding(3) var params: Params; +@group(0) @binding(4) var lookup: texture_2d; +@group(0) @binding(5) var lookup_sampler: sampler; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&op_out); + if i >= count { return; } + + let u = clamp(op_in_u[i] * params.u_scale + params.u_offset, 0.0, 1.0); + let v = clamp(op_in_v[i] * params.v_scale + params.v_offset, 0.0, 1.0); + let c = textureSampleLevel(lookup, lookup_sampler, vec2(u, v), 0.0); + op_out[i] = vec4(c.rgb * params.color_scale, c.a); +} diff --git a/crates/processing_render/src/particles/kernels/attr_mix.wgsl b/crates/processing_render/src/particles/kernels/attr_mix.wgsl new file mode 100644 index 00000000..e9c61376 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attr_mix.wgsl @@ -0,0 +1,23 @@ +struct Params { + t_scale: f32, + t_offset: f32, + t_clamp: u32, +} + +@group(0) @binding(0) var op_a: array; +@group(0) @binding(1) var op_b: array; +@group(0) @binding(2) var op_t: array; +@group(0) @binding(3) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&op_a); + if i >= count { return; } + + var t = op_t[i] * params.t_scale + params.t_offset; + if params.t_clamp != 0u { + t = clamp(t, 0.0, 1.0); + } + op_a[i] = mix(op_a[i], op_b[i], t); +} diff --git a/crates/processing_render/src/particles/kernels/attract.wgsl b/crates/processing_render/src/particles/kernels/attract.wgsl new file mode 100644 index 00000000..e6ef373d --- /dev/null +++ b/crates/processing_render/src/particles/kernels/attract.wgsl @@ -0,0 +1,38 @@ +import processing::particles::falloff; + +struct Params { + center: vec3, + _pad0: f32, + strength: f32, + radius: f32, + falloff_mode: u32, + _pad1: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + let pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + let diff = params.center - pos; + let d2 = dot(diff, diff); + let r2 = params.radius * params.radius; + if d2 > r2 || d2 < 0.000001 { return; } + + let d = sqrt(d2); + let dir = diff / d; + + let fall = falloff(d, params.radius, params.falloff_mode); + + let kick = dir * (params.strength * fall); + velocity[pi] = velocity[pi] + kick.x; + velocity[pi + 1u] = velocity[pi + 1u] + kick.y; + velocity[pi + 2u] = velocity[pi + 2u] + kick.z; +} diff --git a/crates/processing_render/src/particles/kernels/bitonic.wgsl b/crates/processing_render/src/particles/kernels/bitonic.wgsl new file mode 100644 index 00000000..f190a240 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/bitonic.wgsl @@ -0,0 +1,29 @@ +struct Params { + k: u32, + j: u32, +} + +@group(0) @binding(0) var keys: array; +@group(0) @binding(1) var payload: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&keys); + if i >= n { return; } + + let partner = i ^ params.j; + if partner <= i || partner >= n { return; } + + let up = (i & params.k) == 0u; + let ki = keys[i]; + let kp = keys[partner]; + if (ki > kp) == up { + keys[i] = kp; + keys[partner] = ki; + let pi = payload[i]; + payload[i] = payload[partner]; + payload[partner] = pi; + } +} diff --git a/crates/processing_render/src/particles/kernels/bounds_box.wgsl b/crates/processing_render/src/particles/kernels/bounds_box.wgsl new file mode 100644 index 00000000..65c75bba --- /dev/null +++ b/crates/processing_render/src/particles/kernels/bounds_box.wgsl @@ -0,0 +1,67 @@ +struct Params { + aabb_min: vec3, + soft_strength: f32, + aabb_max: vec3, + max_speed: f32, + mode: u32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + var pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + var vel = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); + + let lo = params.aabb_min; + let hi = params.aabb_max; + let size = max(hi - lo, vec3(0.000001)); + + if params.mode == 0u { + if pos.x > hi.x { pos.x = hi.x; vel.x = min(vel.x, 0.0); } + if pos.x < lo.x { pos.x = lo.x; vel.x = max(vel.x, 0.0); } + if pos.y > hi.y { pos.y = hi.y; vel.y = min(vel.y, 0.0); } + if pos.y < lo.y { pos.y = lo.y; vel.y = max(vel.y, 0.0); } + if pos.z > hi.z { pos.z = hi.z; vel.z = min(vel.z, 0.0); } + if pos.z < lo.z { pos.z = lo.z; vel.z = max(vel.z, 0.0); } + } else if params.mode == 1u { + if pos.x > hi.x { pos.x = 2.0 * hi.x - pos.x; if vel.x > 0.0 { vel.x = -vel.x; } } + if pos.x < lo.x { pos.x = 2.0 * lo.x - pos.x; if vel.x < 0.0 { vel.x = -vel.x; } } + if pos.y > hi.y { pos.y = 2.0 * hi.y - pos.y; if vel.y > 0.0 { vel.y = -vel.y; } } + if pos.y < lo.y { pos.y = 2.0 * lo.y - pos.y; if vel.y < 0.0 { vel.y = -vel.y; } } + if pos.z > hi.z { pos.z = 2.0 * hi.z - pos.z; if vel.z > 0.0 { vel.z = -vel.z; } } + if pos.z < lo.z { pos.z = 2.0 * lo.z - pos.z; if vel.z < 0.0 { vel.z = -vel.z; } } + } else if params.mode == 2u { + let rel = pos - lo; + pos = lo + (rel - size * floor(rel / size)); + } else { + let over_hi = max(pos - hi, vec3(0.0)); + let over_lo = max(lo - pos, vec3(0.0)); + vel = vel - params.soft_strength * (over_hi - over_lo); + } + + if params.max_speed > 0.0 { + let speed2 = dot(vel, vel); + let cap2 = params.max_speed * params.max_speed; + if speed2 > cap2 { + vel = vel * (params.max_speed * inverseSqrt(speed2)); + } + } + + position[pi] = pos.x; + position[pi + 1u] = pos.y; + position[pi + 2u] = pos.z; + velocity[pi] = vel.x; + velocity[pi + 1u] = vel.y; + velocity[pi + 2u] = vel.z; +} diff --git a/crates/processing_render/src/particles/kernels/bounds_sphere.wgsl b/crates/processing_render/src/particles/kernels/bounds_sphere.wgsl new file mode 100644 index 00000000..6c342eb2 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/bounds_sphere.wgsl @@ -0,0 +1,60 @@ +struct Params { + center: vec3, + radius: f32, + max_speed: f32, + soft_strength: f32, + mode: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + var pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + var vel = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); + + let offset = pos - params.center; + let d2 = dot(offset, offset); + let r2 = params.radius * params.radius; + + if d2 > r2 && d2 > 0.000001 { + let d = sqrt(d2); + let normal = offset / d; + + if params.mode == 0u { + pos = params.center + normal * params.radius; + let outward = max(0.0, dot(vel, normal)); + vel = vel - normal * outward; + } else if params.mode == 1u { + let overshoot = d - params.radius; + pos = pos - normal * (2.0 * overshoot); + let outward = max(0.0, dot(vel, normal)); + vel = vel - normal * (2.0 * outward); + } else if params.mode == 3u { + vel = vel - normal * (params.soft_strength * (d - params.radius)); + } + } + + if params.max_speed > 0.0 { + let speed2 = dot(vel, vel); + let cap2 = params.max_speed * params.max_speed; + if speed2 > cap2 { + vel = vel * (params.max_speed * inverseSqrt(speed2)); + } + } + + position[pi] = pos.x; + position[pi + 1u] = pos.y; + position[pi + 2u] = pos.z; + velocity[pi] = vel.x; + velocity[pi + 1u] = vel.y; + velocity[pi + 2u] = vel.z; +} diff --git a/crates/processing_render/src/particles/kernels/compact_flag.wgsl b/crates/processing_render/src/particles/kernels/compact_flag.wgsl new file mode 100644 index 00000000..ae71270a --- /dev/null +++ b/crates/processing_render/src/particles/kernels/compact_flag.wgsl @@ -0,0 +1,15 @@ +@group(0) @binding(0) var flags: array; +@group(0) @binding(1) var scanned: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let m = arrayLength(&scanned); + if i >= m { return; } + let n = arrayLength(&flags); + if i < n { + scanned[i] = select(0u, 1u, flags[i] != 0.0); + } else { + scanned[i] = 0u; + } +} diff --git a/crates/processing_render/src/particles/kernels/compact_scatter.wgsl b/crates/processing_render/src/particles/kernels/compact_scatter.wgsl new file mode 100644 index 00000000..f0bcac52 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/compact_scatter.wgsl @@ -0,0 +1,12 @@ +@group(0) @binding(0) var flags: array; +@group(0) @binding(1) var scanned: array; +@group(0) @binding(2) var indices: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&flags) { return; } + if flags[i] != 0.0 { + indices[scanned[i]] = i; + } +} diff --git a/crates/processing_render/src/particles/kernels/drag.wgsl b/crates/processing_render/src/particles/kernels/drag.wgsl new file mode 100644 index 00000000..93737d15 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/drag.wgsl @@ -0,0 +1,29 @@ +struct Params { + damping: f32, + max_speed: f32, +} + +@group(0) @binding(0) var velocity: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&velocity) / 3u; + if i >= count { return; } + + let pi = i * 3u; + let v = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); + let k = clamp(params.damping, 0.0, 1.0); + var new_v = v * (1.0 - k); + if params.max_speed > 0.0 { + let speed2 = dot(new_v, new_v); + let cap2 = params.max_speed * params.max_speed; + if speed2 > cap2 { + new_v = new_v * (params.max_speed * inverseSqrt(speed2)); + } + } + velocity[pi] = new_v.x; + velocity[pi + 1u] = new_v.y; + velocity[pi + 2u] = new_v.z; +} diff --git a/crates/processing_render/src/particles/kernels/field.wgsl b/crates/processing_render/src/particles/kernels/field.wgsl new file mode 100644 index 00000000..009d2958 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/field.wgsl @@ -0,0 +1,33 @@ +import processing::particles::falloff; + +struct Params { + center: vec3, + radius: f32, + falloff_mode: u32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var weight: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + let pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + let diff = pos - params.center; + let d2 = dot(diff, diff); + let r2 = params.radius * params.radius; + + var w: f32 = 0.0; + if d2 < r2 { + w = falloff(sqrt(d2), params.radius, params.falloff_mode); + } + weight[i] = w; +} diff --git a/crates/processing_render/src/particles/kernels/flock.wgsl b/crates/processing_render/src/particles/kernels/flock.wgsl new file mode 100644 index 00000000..56040c17 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/flock.wgsl @@ -0,0 +1,134 @@ +import processing::particles::{cell_coords, cell_index}; + +struct FlockParams { + sep_distance: f32, + neighbor_distance: f32, + weight_separation: f32, + weight_alignment: f32, + weight_cohesion: f32, + max_speed: f32, + max_force: f32, + min_speed: f32, +} + +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var offsets: array; +@group(0) @binding(3) var sorted: array; +@group(0) @binding(4) var fp: FlockParams; +@group(0) @binding(5) var gp: GridParams; + +fn limit_mag(v: vec3, m: f32) -> vec3 { + let len2 = dot(v, v); + if len2 > m * m { return v * (m * inverseSqrt(len2)); } + return v; +} + +fn steer_toward(desired: vec3, vel: vec3, max_speed: f32, max_force: f32) -> vec3 { + let m2 = dot(desired, desired); + if m2 < 0.00000001 { return vec3(0.0); } + return limit_mag(desired * (max_speed * inverseSqrt(m2)) - vel, max_force); +} + +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +fn load_vel(i: u32) -> vec3 { + return vec3(velocity[i * 3u], velocity[i * 3u + 1u], velocity[i * 3u + 2u]); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pos = load_pos(i); + let vel = load_vel(i); + + let sep_d2 = fp.sep_distance * fp.sep_distance; + let neighbor_d2 = fp.neighbor_distance * fp.neighbor_distance; + + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + let bx = base.x; + let by = base.y; + let bz = base.z; + + var sep_steer = vec3(0.0); + var sep_count = 0u; + var ali_sum = vec3(0.0); + var coh_sum = vec3(0.0); + var flock_count = 0u; + + for (var dz = -1; dz <= 1; dz++) { + let cz = bz + dz; + if cz < 0 || cz >= i32(gp.dims_z) { continue; } + for (var dy = -1; dy <= 1; dy++) { + let cy = by + dy; + if cy < 0 || cy >= i32(gp.dims_y) { continue; } + for (var dx = -1; dx <= 1; dx++) { + let cx = bx + dx; + if cx < 0 || cx >= i32(gp.dims_x) { continue; } + + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + if j == i { continue; } + + let diff = pos - load_pos(j); + let d2 = dot(diff, diff); + if d2 > 0.000001 && d2 < neighbor_d2 { + if d2 < sep_d2 { + sep_steer += diff / d2; + sep_count += 1u; + } + ali_sum += load_vel(j); + coh_sum += diff; + flock_count += 1u; + } + } + } + } + } + + var force = vec3(0.0); + if sep_count > 0u { + force += steer_toward(sep_steer / f32(sep_count), vel, + fp.max_speed, fp.max_force) * fp.weight_separation; + } + if flock_count > 0u { + force += steer_toward(ali_sum / f32(flock_count), vel, + fp.max_speed, fp.max_force) * fp.weight_alignment; + force += steer_toward(-coh_sum / f32(flock_count), vel, + fp.max_speed, fp.max_force) * fp.weight_cohesion; + } + + var new_vel = vel + force; + let speed2 = dot(new_vel, new_vel); + let max_speed_sq = fp.max_speed * fp.max_speed; + if speed2 > max_speed_sq { + new_vel = new_vel * (fp.max_speed * inverseSqrt(speed2)); + } else if fp.min_speed > 0.0 { + let min_speed_sq = fp.min_speed * fp.min_speed; + if speed2 < min_speed_sq && speed2 > 0.0 { + new_vel = new_vel * sqrt(min_speed_sq / speed2); + } + } + + velocity[i * 3u] = new_vel.x; + velocity[i * 3u + 1u] = new_vel.y; + velocity[i * 3u + 2u] = new_vel.z; +} diff --git a/crates/processing_render/src/particles/kernels/force.wgsl b/crates/processing_render/src/particles/kernels/force.wgsl new file mode 100644 index 00000000..5a9f55f0 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/force.wgsl @@ -0,0 +1,23 @@ +struct Params { + direction: vec3, + strength: f32, +} + +@group(0) @binding(0) var velocity: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&velocity) / 3u; + if i >= count { return; } + + let len2 = dot(params.direction, params.direction); + if len2 < 0.000001 || params.strength == 0.0 { return; } + let kick = params.direction * (params.strength * inverseSqrt(len2)); + + let pi = i * 3u; + velocity[pi] = velocity[pi] + kick.x; + velocity[pi + 1u] = velocity[pi + 1u] + kick.y; + velocity[pi + 2u] = velocity[pi + 2u] + kick.z; +} diff --git a/crates/processing_render/src/particles/kernels/grid_clear.wgsl b/crates/processing_render/src/particles/kernels/grid_clear.wgsl new file mode 100644 index 00000000..b59f5061 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_clear.wgsl @@ -0,0 +1,8 @@ +@group(0) @binding(0) var counts: array; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&counts) { return; } + counts[i] = 0u; +} diff --git a/crates/processing_render/src/particles/kernels/grid_copy.wgsl b/crates/processing_render/src/particles/kernels/grid_copy.wgsl new file mode 100644 index 00000000..8e5f9991 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_copy.wgsl @@ -0,0 +1,9 @@ +@group(0) @binding(0) var starts: array; +@group(0) @binding(1) var cursor: array; + +@compute @workgroup_size(256) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= arrayLength(&cursor) { return; } + cursor[i] = starts[i]; +} diff --git a/crates/processing_render/src/particles/kernels/grid_count.wgsl b/crates/processing_render/src/particles/kernels/grid_count.wgsl new file mode 100644 index 00000000..949ed6e8 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_count.wgsl @@ -0,0 +1,24 @@ +import processing::particles::cell_of; + +struct Params { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var counts: array>; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&position) / 3u; + if i >= n { return; } + let p = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let dims = vec3(params.dims_x, params.dims_y, params.dims_z); + atomicAdd(&counts[cell_of(p, params.grid_min, params.cell_size, dims)], 1u); +} diff --git a/crates/processing_render/src/particles/kernels/grid_scatter.wgsl b/crates/processing_render/src/particles/kernels/grid_scatter.wgsl new file mode 100644 index 00000000..98be9ace --- /dev/null +++ b/crates/processing_render/src/particles/kernels/grid_scatter.wgsl @@ -0,0 +1,26 @@ +import processing::particles::cell_of; + +struct Params { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var cursor: array>; +@group(0) @binding(2) var sorted: array; +@group(0) @binding(3) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let n = arrayLength(&position) / 3u; + if i >= n { return; } + let p = vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); + let dims = vec3(params.dims_x, params.dims_y, params.dims_z); + let slot = atomicAdd(&cursor[cell_of(p, params.grid_min, params.cell_size, dims)], 1u); + sorted[slot] = i; +} diff --git a/crates/processing_render/src/particles/kernels/impulse.wgsl b/crates/processing_render/src/particles/kernels/impulse.wgsl new file mode 100644 index 00000000..0d6d1431 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/impulse.wgsl @@ -0,0 +1,43 @@ +import processing::particles::falloff; + +struct Params { + center: vec3, + radius: f32, + position_kick: f32, + velocity_kick: f32, + falloff_mode: u32, + _pad: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + let pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + let diff = pos - params.center; + let d2 = dot(diff, diff); + let r2 = params.radius * params.radius; + if d2 > r2 || d2 < 0.000001 { return; } + + let d = sqrt(d2); + let dir = diff / d; + + let fall = falloff(d, params.radius, params.falloff_mode); + + let pos_push = dir * (params.position_kick * fall); + let vel_push = dir * (params.velocity_kick * fall); + + position[pi] = position[pi] + pos_push.x; + position[pi + 1u] = position[pi + 1u] + pos_push.y; + position[pi + 2u] = position[pi + 2u] + pos_push.z; + velocity[pi] = velocity[pi] + vel_push.x; + velocity[pi + 1u] = velocity[pi + 1u] + vel_push.y; + velocity[pi + 2u] = velocity[pi + 2u] + vel_push.z; +} diff --git a/crates/processing_render/src/particles/kernels/integrate.wgsl b/crates/processing_render/src/particles/kernels/integrate.wgsl new file mode 100644 index 00000000..22a82afd --- /dev/null +++ b/crates/processing_render/src/particles/kernels/integrate.wgsl @@ -0,0 +1,19 @@ +struct Params { + dt: f32, +} + +@group(0) @binding(0) var velocity: array; +@group(0) @binding(1) var position: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pi = i * 3u; + position[pi] = position[pi] + velocity[pi] * params.dt; + position[pi + 1u] = position[pi + 1u] + velocity[pi + 1u] * params.dt; + position[pi + 2u] = position[pi + 2u] + velocity[pi + 2u] * params.dt; +} diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs new file mode 100644 index 00000000..a552af51 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -0,0 +1,337 @@ +//! built-in compute kernels for [`Particles`](super::Particles), embedded as +//! assets and dispatched via `particles_apply`. + +use bevy::asset::embedded_asset; +use bevy::mesh::VertexAttributeValues; +use bevy::prelude::*; + +use processing_core::app_mut; +use processing_core::error::{self, ProcessingError}; + +use crate::geometry::{BuiltinAttributes, Geometry}; +use crate::shader_value::ShaderValue; +use crate::{compute_create, compute_set, shader_load}; + +#[derive(Component, Default, Clone)] +pub struct KernelRequires(pub Vec); + +fn set_requires(compute: Entity, names: &[&str]) -> error::Result<()> { + app_mut(|app| { + let world = app.world_mut(); + let attrs: Vec = { + let builtins = world.resource::(); + names.iter().filter_map(|n| builtins.by_name(n)).collect() + }; + world.entity_mut(compute).insert(KernelRequires(attrs)); + Ok(()) + }) +} + +pub struct ParticlesKernelsPlugin; + +impl Plugin for ParticlesKernelsPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "noise.wgsl"); + embedded_asset!(app, "transform.wgsl"); + embedded_asset!(app, "attract.wgsl"); + embedded_asset!(app, "drag.wgsl"); + embedded_asset!(app, "force.wgsl"); + embedded_asset!(app, "integrate.wgsl"); + embedded_asset!(app, "age.wgsl"); + embedded_asset!(app, "vortex.wgsl"); + embedded_asset!(app, "bounds_sphere.wgsl"); + embedded_asset!(app, "bounds_box.wgsl"); + embedded_asset!(app, "impulse.wgsl"); + embedded_asset!(app, "flock.wgsl"); + embedded_asset!(app, "orient.wgsl"); + embedded_asset!(app, "field.wgsl"); + embedded_asset!(app, "attr_linear.wgsl"); + embedded_asset!(app, "attr_combine.wgsl"); + embedded_asset!(app, "attr_mix.wgsl"); + embedded_asset!(app, "attr_lookup1d.wgsl"); + embedded_asset!(app, "attr_lookup2d.wgsl"); + embedded_asset!(app, "scatter_surface.wgsl"); + embedded_asset!(app, "scatter_volume.wgsl"); + embedded_asset!(app, "scan_block.wgsl"); + embedded_asset!(app, "scan_add.wgsl"); + embedded_asset!(app, "grid_clear.wgsl"); + embedded_asset!(app, "grid_count.wgsl"); + embedded_asset!(app, "grid_copy.wgsl"); + embedded_asset!(app, "grid_scatter.wgsl"); + embedded_asset!(app, "bitonic.wgsl"); + embedded_asset!(app, "compact_flag.wgsl"); + embedded_asset!(app, "compact_scatter.wgsl"); + embedded_asset!(app, "reduce.wgsl"); + embedded_asset!(app, "neighbor.wgsl"); + } +} + +pub const FALLOFF_CONST: u32 = 0; +pub const FALLOFF_LINEAR: u32 = 1; +pub const FALLOFF_SMOOTHSTEP: u32 = 2; +pub const FALLOFF_QUADRATIC: u32 = 3; +pub const FALLOFF_CUBIC: u32 = 4; +pub const FALLOFF_INVERSE: u32 = 5; + +pub const BOUNDS_CLAMP: u32 = 0; +pub const BOUNDS_REFLECT: u32 = 1; +pub const BOUNDS_WRAP: u32 = 2; +pub const BOUNDS_SOFT: u32 = 3; + +pub const COMBINE_ADD: u32 = 0; +pub const COMBINE_SUB: u32 = 1; +pub const COMBINE_MUL: u32 = 2; +pub const COMBINE_DIV: u32 = 3; +pub const COMBINE_MIN: u32 = 4; +pub const COMBINE_MAX: u32 = 5; +pub const COMBINE_POW: u32 = 6; + +pub fn particles_kernel_noise() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/noise.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position"])?; + compute_set(entity, "divergence_free", ShaderValue::UInt(0))?; + Ok(entity) +} + +pub fn particles_kernel_transform() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/transform.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position"])?; + compute_set(entity, "translate", ShaderValue::Float3([0.0; 3]))?; + compute_set( + entity, + "rotation_axis", + ShaderValue::Float3([0.0, 1.0, 0.0]), + )?; + compute_set(entity, "rotation_angle", ShaderValue::Float(0.0))?; + compute_set(entity, "scale", ShaderValue::Float3([1.0, 1.0, 1.0]))?; + Ok(entity) +} + +pub fn particles_kernel_attract() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attract.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "strength", ShaderValue::Float(0.0))?; + compute_set(entity, "radius", ShaderValue::Float(1.0))?; + compute_set(entity, "falloff_mode", ShaderValue::UInt(FALLOFF_LINEAR))?; + Ok(entity) +} + +pub fn particles_kernel_drag() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/drag.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["velocity"])?; + compute_set(entity, "damping", ShaderValue::Float(0.0))?; + compute_set(entity, "max_speed", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_force() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/force.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["velocity"])?; + compute_set(entity, "direction", ShaderValue::Float3([0.0, -1.0, 0.0]))?; + compute_set(entity, "strength", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_integrate() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/integrate.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "dt", ShaderValue::Float(1.0))?; + Ok(entity) +} + +pub fn particles_kernel_age() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/age.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["age", "life"])?; + compute_set(entity, "dt", ShaderValue::Float(1.0))?; + Ok(entity) +} + +pub fn particles_kernel_vortex() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/vortex.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "axis", ShaderValue::Float3([0.0, 1.0, 0.0]))?; + compute_set(entity, "strength", ShaderValue::Float(0.0))?; + compute_set(entity, "radius", ShaderValue::Float(1.0))?; + compute_set(entity, "falloff_mode", ShaderValue::UInt(FALLOFF_LINEAR))?; + Ok(entity) +} + +pub fn particles_kernel_bounds_sphere() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/bounds_sphere.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "radius", ShaderValue::Float(1.0))?; + compute_set(entity, "mode", ShaderValue::UInt(BOUNDS_SOFT))?; + compute_set(entity, "soft_strength", ShaderValue::Float(0.01))?; + compute_set(entity, "max_speed", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_bounds_box() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/bounds_box.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "aabb_min", ShaderValue::Float3([-1.0, -1.0, -1.0]))?; + compute_set(entity, "aabb_max", ShaderValue::Float3([1.0, 1.0, 1.0]))?; + compute_set(entity, "mode", ShaderValue::UInt(BOUNDS_SOFT))?; + compute_set(entity, "soft_strength", ShaderValue::Float(0.01))?; + compute_set(entity, "max_speed", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_bounds_geometry(geometry_entity: Entity) -> error::Result { + let (aabb_min, aabb_max) = app_mut(|app| { + app.world_mut() + .run_system_cached_with(extract_geometry_aabb, geometry_entity) + .unwrap() + })?; + let entity = particles_kernel_bounds_box()?; + compute_set(entity, "aabb_min", ShaderValue::Float3(aabb_min))?; + compute_set(entity, "aabb_max", ShaderValue::Float3(aabb_max))?; + Ok(entity) +} + +fn extract_geometry_aabb( + In(geom_entity): In, + geometries: Query<&Geometry>, + meshes: Res>, +) -> error::Result<([f32; 3], [f32; 3])> { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)?; + let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) { + Some(VertexAttributeValues::Float32x3(p)) => p, + _ => { + return Err(ProcessingError::InvalidArgument( + "bounds geometry has no Float32x3 position attribute".to_string(), + )); + } + }; + if positions.is_empty() { + return Err(ProcessingError::InvalidArgument( + "bounds geometry has no vertices".to_string(), + )); + } + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for p in positions { + let v = Vec3::from_array(*p); + min = min.min(v); + max = max.max(v); + } + Ok((min.to_array(), max.to_array())) +} + +pub fn particles_kernel_impulse() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/impulse.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "radius", ShaderValue::Float(1.0))?; + compute_set(entity, "position_kick", ShaderValue::Float(0.0))?; + compute_set(entity, "velocity_kick", ShaderValue::Float(0.0))?; + compute_set( + entity, + "falloff_mode", + ShaderValue::UInt(FALLOFF_SMOOTHSTEP), + )?; + Ok(entity) +} + +pub fn particles_kernel_flock() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/flock.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position", "velocity"])?; + compute_set(entity, "sep_distance", ShaderValue::Float(1.2))?; + compute_set(entity, "neighbor_distance", ShaderValue::Float(2.5))?; + compute_set(entity, "weight_separation", ShaderValue::Float(1.5))?; + compute_set(entity, "weight_alignment", ShaderValue::Float(1.0))?; + compute_set(entity, "weight_cohesion", ShaderValue::Float(1.0))?; + compute_set(entity, "max_speed", ShaderValue::Float(0.1))?; + compute_set(entity, "max_force", ShaderValue::Float(0.003))?; + compute_set(entity, "min_speed", ShaderValue::Float(0.02))?; + Ok(entity) +} + +pub fn particles_kernel_orient() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/orient.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["velocity", "rotation"])?; + compute_set(entity, "forward", ShaderValue::Float3([0.0, 0.0, 1.0]))?; + compute_set(entity, "up", ShaderValue::Float3([0.0, 1.0, 0.0]))?; + Ok(entity) +} + +pub fn particles_kernel_field() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/field.wgsl")?; + let entity = compute_create(shader)?; + set_requires(entity, &["position"])?; + compute_set(entity, "center", ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "radius", ShaderValue::Float(1.0))?; + compute_set( + entity, + "falloff_mode", + ShaderValue::UInt(FALLOFF_SMOOTHSTEP), + )?; + Ok(entity) +} + +// Bind `op*` buffers directly, not named attributes, so they declare no requires. +pub fn particles_kernel_attr_linear() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attr_linear.wgsl")?; + let entity = compute_create(shader)?; + compute_set(entity, "scale", ShaderValue::Float(1.0))?; + compute_set(entity, "offset", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_attr_combine() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attr_combine.wgsl")?; + let entity = compute_create(shader)?; + compute_set(entity, "op", ShaderValue::UInt(COMBINE_ADD))?; + compute_set(entity, "b_scale", ShaderValue::Float(1.0))?; + compute_set(entity, "b_offset", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_attr_mix() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attr_mix.wgsl")?; + let entity = compute_create(shader)?; + compute_set(entity, "t_scale", ShaderValue::Float(1.0))?; + compute_set(entity, "t_offset", ShaderValue::Float(0.0))?; + compute_set(entity, "t_clamp", ShaderValue::UInt(1))?; + Ok(entity) +} + +pub fn particles_kernel_attr_lookup1d() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attr_lookup1d.wgsl")?; + let entity = compute_create(shader)?; + compute_set(entity, "scale", ShaderValue::Float(1.0))?; + compute_set(entity, "offset", ShaderValue::Float(0.0))?; + Ok(entity) +} + +pub fn particles_kernel_attr_lookup2d() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/attr_lookup2d.wgsl")?; + let entity = compute_create(shader)?; + compute_set(entity, "u_scale", ShaderValue::Float(1.0))?; + compute_set(entity, "u_offset", ShaderValue::Float(0.0))?; + compute_set(entity, "v_scale", ShaderValue::Float(1.0))?; + compute_set(entity, "v_offset", ShaderValue::Float(0.0))?; + compute_set(entity, "color_scale", ShaderValue::Float(1.0))?; + Ok(entity) +} diff --git a/crates/processing_render/src/particles/kernels/neighbor.wgsl b/crates/processing_render/src/particles/kernels/neighbor.wgsl new file mode 100644 index 00000000..966b412f --- /dev/null +++ b/crates/processing_render/src/particles/kernels/neighbor.wgsl @@ -0,0 +1,94 @@ +import processing::particles::{cell_coords, cell_index, falloff}; + +struct GridParams { + grid_min: vec3, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, + _pad: u32, +} + +struct Params { + radius: f32, + op: u32, + falloff_mode: u32, + components: u32, +} + +const OP_SUM: u32 = 0u; +const OP_MEAN: u32 = 1u; +const OP_COUNT: u32 = 2u; + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var source: array; +@group(0) @binding(2) var out: array; +@group(0) @binding(3) var offsets: array; +@group(0) @binding(4) var sorted: array; +@group(0) @binding(5) var params: Params; +@group(0) @binding(6) var gp: GridParams; + +fn load_pos(i: u32) -> vec3 { + return vec3(position[i * 3u], position[i * 3u + 1u], position[i * 3u + 2u]); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let pos = load_pos(i); + let r2 = params.radius * params.radius; + let comps = params.components; + + let dims = vec3(gp.dims_x, gp.dims_y, gp.dims_z); + let base = cell_coords(pos, gp.grid_min, gp.cell_size, dims); + + var value = array(0.0, 0.0, 0.0, 0.0); + var weight_sum = 0.0; + + for (var dz = -1; dz <= 1; dz++) { + let cz = base.z + dz; + if cz < 0 || cz >= i32(gp.dims_z) { continue; } + for (var dy = -1; dy <= 1; dy++) { + let cy = base.y + dy; + if cy < 0 || cy >= i32(gp.dims_y) { continue; } + for (var dx = -1; dx <= 1; dx++) { + let cx = base.x + dx; + if cx < 0 || cx >= i32(gp.dims_x) { continue; } + + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + let end = offsets[cell + 1u]; + for (var s = start; s < end; s++) { + let j = sorted[s]; + let diff = pos - load_pos(j); + let d2 = dot(diff, diff); + if d2 <= r2 { + let w = falloff(sqrt(d2), params.radius, params.falloff_mode); + weight_sum += w; + if params.op != OP_COUNT { + for (var c = 0u; c < comps; c++) { + value[c] += w * source[j * comps + c]; + } + } + } + } + } + } + } + + if params.op == OP_COUNT { + out[i] = weight_sum; + } else if params.op == OP_MEAN { + let inv = select(0.0, 1.0 / weight_sum, weight_sum > 0.0); + for (var c = 0u; c < comps; c++) { + out[i * comps + c] = value[c] * inv; + } + } else { + for (var c = 0u; c < comps; c++) { + out[i * comps + c] = value[c]; + } + } +} diff --git a/crates/processing_render/src/particles/kernels/noise.wgsl b/crates/processing_render/src/particles/kernels/noise.wgsl new file mode 100644 index 00000000..65053475 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/noise.wgsl @@ -0,0 +1,90 @@ +struct Params { + scale: f32, + strength: f32, + time: f32, + divergence_free: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +fn hash(p: vec3) -> f32 { + let q = fract(p * 0.3183099) + vec3(0.1, 0.2, 0.3); + let r = q + dot(q, q.yzx + 19.19); + return fract(r.x * r.y * r.z); +} + +fn value_noise(p: vec3) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(hash(i + vec3(0.0, 0.0, 0.0)), + hash(i + vec3(1.0, 0.0, 0.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 0.0)), + hash(i + vec3(1.0, 1.0, 0.0)), u.x), + u.y), + mix( + mix(hash(i + vec3(0.0, 0.0, 1.0)), + hash(i + vec3(1.0, 0.0, 1.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 1.0)), + hash(i + vec3(1.0, 1.0, 1.0)), u.x), + u.y), + u.z); +} + +fn noise3(p: vec3) -> vec3 { + return vec3( + value_noise(p), + value_noise(p + vec3(31.4, 0.0, 0.0)), + value_noise(p + vec3(0.0, 71.7, 0.0)), + ) * 2.0 - 1.0; +} + +fn curl_noise(p: vec3, eps: f32) -> vec3 { + let dx = vec3(eps, 0.0, 0.0); + let dy = vec3(0.0, eps, 0.0); + let dz = vec3(0.0, 0.0, eps); + + let n_xp = noise3(p + dx); let n_xm = noise3(p - dx); + let n_yp = noise3(p + dy); let n_ym = noise3(p - dy); + let n_zp = noise3(p + dz); let n_zm = noise3(p - dz); + + let inv = 1.0 / (2.0 * eps); + let dn_dx = (n_xp - n_xm) * inv; + let dn_dy = (n_yp - n_ym) * inv; + let dn_dz = (n_zp - n_zm) * inv; + + return vec3( + dn_dy.z - dn_dz.y, + dn_dz.x - dn_dx.z, + dn_dx.y - dn_dy.x, + ); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + let sample = p * params.scale + vec3(params.time, params.time * 0.7, params.time * 1.3); + var n: vec3; + if params.divergence_free == 1u { + let eps = 0.5 / max(params.scale, 1.0); + n = curl_noise(sample, eps); + } else { + n = noise3(sample); + } + let new_p = p + n * params.strength; + position[i * 3u + 0u] = new_p.x; + position[i * 3u + 1u] = new_p.y; + position[i * 3u + 2u] = new_p.z; +} diff --git a/crates/processing_render/src/particles/kernels/orient.wgsl b/crates/processing_render/src/particles/kernels/orient.wgsl new file mode 100644 index 00000000..6260cc64 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/orient.wgsl @@ -0,0 +1,115 @@ +struct Params { + forward: vec3, + _pad0: f32, + up: vec3, + _pad1: f32, +} + +@group(0) @binding(0) var velocity: array; +@group(0) @binding(1) var rotation: array; +@group(0) @binding(2) var params: Params; + +fn quat_from_to(src: vec3, dst: vec3) -> vec4 { + let d = dot(src, dst); + if d > 0.999999 { + return vec4(0.0, 0.0, 0.0, 1.0); + } + if d < -0.999999 { + var axis = cross(src, vec3(1.0, 0.0, 0.0)); + if dot(axis, axis) < 0.001 { + axis = cross(src, vec3(0.0, 1.0, 0.0)); + } + return vec4(normalize(axis), 0.0); + } + let axis = cross(src, dst); + return normalize(vec4(axis, 1.0 + d)); +} + +fn mat3_to_quat(m: mat3x3) -> vec4 { + let trace = m[0][0] + m[1][1] + m[2][2]; + if trace > 0.0 { + let s = sqrt(trace + 1.0) * 2.0; + return vec4( + (m[1][2] - m[2][1]) / s, + (m[2][0] - m[0][2]) / s, + (m[0][1] - m[1][0]) / s, + 0.25 * s, + ); + } + if m[0][0] > m[1][1] && m[0][0] > m[2][2] { + let s = sqrt(1.0 + m[0][0] - m[1][1] - m[2][2]) * 2.0; + return vec4( + 0.25 * s, + (m[1][0] + m[0][1]) / s, + (m[2][0] + m[0][2]) / s, + (m[1][2] - m[2][1]) / s, + ); + } + if m[1][1] > m[2][2] { + let s = sqrt(1.0 + m[1][1] - m[0][0] - m[2][2]) * 2.0; + return vec4( + (m[1][0] + m[0][1]) / s, + 0.25 * s, + (m[2][1] + m[1][2]) / s, + (m[2][0] - m[0][2]) / s, + ); + } + let s = sqrt(1.0 + m[2][2] - m[0][0] - m[1][1]) * 2.0; + return vec4( + (m[2][0] + m[0][2]) / s, + (m[2][1] + m[1][2]) / s, + 0.25 * s, + (m[0][1] - m[1][0]) / s, + ); +} + +fn write_quat(i: u32, q: vec4) { + let ri = i * 4u; + rotation[ri] = q.x; + rotation[ri + 1u] = q.y; + rotation[ri + 2u] = q.z; + rotation[ri + 3u] = q.w; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&velocity) / 3u; + if i >= count { return; } + + let pi = i * 3u; + let v = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); + let v_len2 = dot(v, v); + if v_len2 < 0.000001 { return; } + let vel_dir = v * inverseSqrt(v_len2); + + let fwd_len2 = dot(params.forward, params.forward); + if fwd_len2 < 0.000001 { return; } + let fwd = params.forward * inverseSqrt(fwd_len2); + + let up_param_len2 = dot(params.up, params.up); + if up_param_len2 < 0.000001 { + write_quat(i, quat_from_to(fwd, vel_dir)); + return; + } + + var up_local = params.up - dot(params.up, fwd) * fwd; + let up_local_len2 = dot(up_local, up_local); + var up_target = params.up - dot(params.up, vel_dir) * vel_dir; + let up_target_len2 = dot(up_target, up_target); + if up_local_len2 < 0.000001 || up_target_len2 < 0.000001 { + write_quat(i, quat_from_to(fwd, vel_dir)); + return; + } + up_local = up_local * inverseSqrt(up_local_len2); + up_target = up_target * inverseSqrt(up_target_len2); + + let right_local = cross(fwd, up_local); + let right_target = cross(vel_dir, up_target); + + let local_col = mat3x3(fwd, up_local, right_local); + let target_col = mat3x3(vel_dir, up_target, right_target); + let r = target_col * transpose(local_col); + + write_quat(i, mat3_to_quat(r)); +} diff --git a/crates/processing_render/src/particles/kernels/reduce.wgsl b/crates/processing_render/src/particles/kernels/reduce.wgsl new file mode 100644 index 00000000..9d0f97e4 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/reduce.wgsl @@ -0,0 +1,52 @@ +struct Params { + mode: u32, + count: u32, +} + +const BLOCK: u32 = 256u; + +@group(0) @binding(0) var input: array; +@group(0) @binding(1) var output: array; +@group(0) @binding(2) var params: Params; + +var sdata: array; + +fn ident(mode: u32) -> f32 { + if mode == 1u { return 3.4e38; } + if mode == 2u { return -3.4e38; } + return 0.0; +} + +fn combine(a: f32, b: f32, mode: u32) -> f32 { + if mode == 1u { return min(a, b); } + if mode == 2u { return max(a, b); } + return a + b; +} + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + var v = ident(params.mode); + if gid.x < params.count { + v = input[gid.x]; + } + sdata[lid.x] = v; + workgroupBarrier(); + + var stride = BLOCK / 2u; + loop { + if stride == 0u { break; } + if lid.x < stride { + sdata[lid.x] = combine(sdata[lid.x], sdata[lid.x + stride], params.mode); + } + workgroupBarrier(); + stride = stride >> 1u; + } + + if lid.x == 0u { + output[wid.x] = sdata[0]; + } +} diff --git a/crates/processing_render/src/particles/kernels/scan_add.wgsl b/crates/processing_render/src/particles/kernels/scan_add.wgsl new file mode 100644 index 00000000..918d72d4 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scan_add.wgsl @@ -0,0 +1,13 @@ +@group(0) @binding(0) var data: array; +@group(0) @binding(1) var block_sums: array; + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + let n = arrayLength(&data); + let g = gid.x; + if g >= n { return; } + data[g] = data[g] + block_sums[wid.x]; +} diff --git a/crates/processing_render/src/particles/kernels/scan_block.wgsl b/crates/processing_render/src/particles/kernels/scan_block.wgsl new file mode 100644 index 00000000..aea9824a --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scan_block.wgsl @@ -0,0 +1,34 @@ +const BLOCK: u32 = 256u; + +@group(0) @binding(0) var data: array; +@group(0) @binding(1) var block_sums: array; + +var tmp: array; + +@compute @workgroup_size(256) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, + @builtin(workgroup_id) wid: vec3, +) { + let n = arrayLength(&data); + let g = gid.x; + let l = lid.x; + + var v: u32 = 0u; + if g < n { v = data[g]; } + tmp[l] = v; + workgroupBarrier(); + + for (var offset: u32 = 1u; offset < BLOCK; offset = offset << 1u) { + var add: u32 = 0u; + if l >= offset { add = tmp[l - offset]; } + workgroupBarrier(); + tmp[l] = tmp[l] + add; + workgroupBarrier(); + } + + if g < n { data[g] = tmp[l] - v; } + + if l == 0u { block_sums[wid.x] = tmp[BLOCK - 1u]; } +} diff --git a/crates/processing_render/src/particles/kernels/scatter_surface.wgsl b/crates/processing_render/src/particles/kernels/scatter_surface.wgsl new file mode 100644 index 00000000..a9fa3b60 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scatter_surface.wgsl @@ -0,0 +1,99 @@ +struct Params { + face_count: u32, + seed: u32, + _pad0: u32, + _pad1: u32, +} + +struct EmitRange { + emit_base: u32, + emit_count: u32, + emit_capacity: u32, + _pad: u32, +} + +@group(0) @binding(0) var source_position: array; +@group(0) @binding(1) var source_indices: array; +@group(0) @binding(2) var cdf: array; +@group(0) @binding(3) var position: array; +@group(0) @binding(4) var scale: array; +@group(0) @binding(5) var age: array; +@group(0) @binding(6) var life: array; +@group(0) @binding(7) var params: Params; +@group(0) @binding(8) var emit_range: EmitRange; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +fn cdf_search(u: f32) -> u32 { + var lo: u32 = 0u; + var hi: u32 = params.face_count; + loop { + if lo >= hi { break; } + let mid = (lo + hi) >> 1u; + if cdf[mid] < u { + lo = mid + 1u; + } else { + hi = mid; + } + } + return min(lo, params.face_count - 1u); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= emit_range.emit_count { return; } + let base = emit_range.emit_base; + let cap = emit_range.emit_capacity; + let slot = (base + local_i) % cap; + let seed = params.seed ^ (base + local_i); + + let u01 = hash_unit(seed * 7u + 13u); + let face = cdf_search(u01); + + let i0 = source_indices[face * 3u + 0u]; + let i1 = source_indices[face * 3u + 1u]; + let i2 = source_indices[face * 3u + 2u]; + + let p0 = vec3( + source_position[i0 * 3u + 0u], + source_position[i0 * 3u + 1u], + source_position[i0 * 3u + 2u], + ); + let p1 = vec3( + source_position[i1 * 3u + 0u], + source_position[i1 * 3u + 1u], + source_position[i1 * 3u + 2u], + ); + let p2 = vec3( + source_position[i2 * 3u + 0u], + source_position[i2 * 3u + 1u], + source_position[i2 * 3u + 2u], + ); + + var u = hash_unit(seed * 31u + 23u); + var v = hash_unit(seed * 47u + 29u); + if u + v > 1.0 { u = 1.0 - u; v = 1.0 - v; } + let p = (1.0 - u - v) * p0 + u * p1 + v * p2; + + position[slot * 3u + 0u] = p.x; + position[slot * 3u + 1u] = p.y; + position[slot * 3u + 2u] = p.z; + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + age[slot] = 0.0; + life[slot] = 1.0; +} diff --git a/crates/processing_render/src/particles/kernels/scatter_volume.wgsl b/crates/processing_render/src/particles/kernels/scatter_volume.wgsl new file mode 100644 index 00000000..47f0b2de --- /dev/null +++ b/crates/processing_render/src/particles/kernels/scatter_volume.wgsl @@ -0,0 +1,119 @@ +struct Params { + aabb_min: vec4, + aabb_max: vec4, + face_count: u32, + max_attempts: u32, + seed: u32, + _pad: u32, +} + +struct EmitRange { + emit_base: u32, + emit_count: u32, + emit_capacity: u32, + _pad: u32, +} + +@group(0) @binding(0) var source_position: array; +@group(0) @binding(1) var source_indices: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var life: array; +@group(0) @binding(6) var params: Params; +@group(0) @binding(7) var emit_range: EmitRange; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +fn fetch_vertex(i: u32) -> vec3 { + return vec3( + source_position[i * 3u + 0u], + source_position[i * 3u + 1u], + source_position[i * 3u + 2u], + ); +} + +fn ray_triangle( + ro: vec3, rd: vec3, + p0: vec3, p1: vec3, p2: vec3, +) -> f32 { + let e1 = p1 - p0; + let e2 = p2 - p0; + let h = cross(rd, e2); + let a = dot(e1, h); + if abs(a) < 1e-7 { return -1.0; } + let f = 1.0 / a; + let s = ro - p0; + let u = f * dot(s, h); + if u < 0.0 || u > 1.0 { return -1.0; } + let q = cross(s, e1); + let v = f * dot(rd, q); + if v < 0.0 || (u + v) > 1.0 { return -1.0; } + return f * dot(e2, q); +} + +fn point_inside(p: vec3) -> bool { + let rd = normalize(vec3(0.5773, 0.5774, 0.5775)); + var hits = 0u; + for (var f = 0u; f < params.face_count; f = f + 1u) { + let i0 = source_indices[f * 3u + 0u]; + let i1 = source_indices[f * 3u + 1u]; + let i2 = source_indices[f * 3u + 2u]; + let t = ray_triangle(p, rd, fetch_vertex(i0), fetch_vertex(i1), fetch_vertex(i2)); + if t >= 0.0 { + hits = hits + 1u; + } + } + return (hits & 1u) == 1u; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= emit_range.emit_count { return; } + let base = emit_range.emit_base; + let cap = emit_range.emit_capacity; + let slot = (base + local_i) % cap; + let seed_base = params.seed ^ (base + local_i); + + let lo = params.aabb_min.xyz; + let hi = params.aabb_max.xyz; + + var p = vec3(0.0); + var found = false; + for (var attempt = 0u; attempt < params.max_attempts; attempt = attempt + 1u) { + let s = seed_base ^ (attempt * 0x9e3779b9u); + p = vec3( + mix(lo.x, hi.x, hash_unit(s * 7u + 13u)), + mix(lo.y, hi.y, hash_unit(s * 31u + 23u)), + mix(lo.z, hi.z, hash_unit(s * 47u + 29u)), + ); + if point_inside(p) { + found = true; + break; + } + } + + if !found { return; } + + position[slot * 3u + 0u] = p.x; + position[slot * 3u + 1u] = p.y; + position[slot * 3u + 2u] = p.z; + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + age[slot] = 0.0; + life[slot] = 1.0; +} diff --git a/crates/processing_render/src/particles/kernels/transform.wgsl b/crates/processing_render/src/particles/kernels/transform.wgsl new file mode 100644 index 00000000..2e96a4e6 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/transform.wgsl @@ -0,0 +1,43 @@ +struct Params { + translate: vec3, + rotation_angle: f32, + rotation_axis: vec3, + scale: vec3, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +fn rotate(p: vec3, axis: vec3, angle: f32) -> vec3 { + let c = cos(angle); + let s = sin(angle); + return p * c + cross(axis, p) * s + axis * dot(axis, p) * (1.0 - c); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + + var p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + + p = p * params.scale; + + let axis_len = length(params.rotation_axis); + if axis_len > 1.0e-6 && abs(params.rotation_angle) > 1.0e-8 { + p = rotate(p, params.rotation_axis / axis_len, params.rotation_angle); + } + + p = p + params.translate; + + position[i * 3u + 0u] = p.x; + position[i * 3u + 1u] = p.y; + position[i * 3u + 2u] = p.z; +} diff --git a/crates/processing_render/src/particles/kernels/vortex.wgsl b/crates/processing_render/src/particles/kernels/vortex.wgsl new file mode 100644 index 00000000..48a899fa --- /dev/null +++ b/crates/processing_render/src/particles/kernels/vortex.wgsl @@ -0,0 +1,47 @@ +import processing::particles::falloff; + +struct Params { + center: vec3, + _pad0: f32, + axis: vec3, + _pad1: f32, + strength: f32, + radius: f32, + falloff_mode: u32, + _pad2: u32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + + let axis_len2 = dot(params.axis, params.axis); + if axis_len2 < 0.000001 { return; } + let axis = params.axis * inverseSqrt(axis_len2); + + let pi = i * 3u; + let pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + let offset = pos - params.center; + + let parallel = dot(offset, axis) * axis; + let radial = offset - parallel; + let r2 = dot(radial, radial); + let max_r2 = params.radius * params.radius; + if r2 > max_r2 || r2 < 0.000001 { return; } + + let r = sqrt(r2); + let tangent = cross(axis, radial / r); + + let fall = falloff(r, params.radius, params.falloff_mode); + + let kick = tangent * (params.strength * fall); + velocity[pi] = velocity[pi] + kick.x; + velocity[pi + 1u] = velocity[pi + 1u] + kick.y; + velocity[pi + 2u] = velocity[pi + 2u] + kick.z; +} diff --git a/crates/processing_render/src/particles/material.rs b/crates/processing_render/src/particles/material.rs new file mode 100644 index 00000000..90b55a98 --- /dev/null +++ b/crates/processing_render/src/particles/material.rs @@ -0,0 +1,90 @@ +use std::ops::Deref; + +use bevy::asset::embedded_asset; +use bevy::material::specialize::SpecializedMeshPipelineError; +use bevy::pbr::{ + ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline, + MaterialPlugin, +}; +use bevy::prelude::*; +use bevy::render::{ + mesh::MeshVertexBufferLayoutRef, + render_resource::{AsBindGroup, RenderPipelineDescriptor}, + storage::ShaderBuffer, +}; +use bevy::shader::ShaderRef; + +use crate::render::material::UntypedMaterial; + +pub struct ParticlesMaterialPlugin; + +impl Plugin for ParticlesMaterialPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "particles.wgsl"); + app.add_plugins(MaterialPlugin::::default()); + } +} + +pub type ParticlesMaterial = ExtendedMaterial; + +#[derive(Copy, Clone, PartialEq, Eq, Hash)] +pub struct ParticlesExtensionKey { + pub has_albedo: bool, + pub has_emissive: bool, +} + +impl From<&ParticlesExtension> for ParticlesExtensionKey { + fn from(ext: &ParticlesExtension) -> Self { + Self { + has_albedo: ext.colors.is_some(), + has_emissive: ext.emissive_colors.is_some(), + } + } +} + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] +#[bind_group_data(ParticlesExtensionKey)] +pub struct ParticlesExtension { + #[storage(100, read_only)] + pub colors: Option>, + #[storage(101, read_only)] + pub emissive_colors: Option>, +} + +impl MaterialExtension for ParticlesExtension { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/particles/particles.wgsl".into() + } + + fn deferred_fragment_shader() -> ShaderRef { + "embedded://processing_render/particles/particles.wgsl".into() + } + + fn specialize( + _pipeline: &MaterialExtensionPipeline, + descriptor: &mut RenderPipelineDescriptor, + _layout: &MeshVertexBufferLayoutRef, + key: MaterialExtensionKey, + ) -> Result<(), SpecializedMeshPipelineError> { + if let Some(ref mut fragment) = descriptor.fragment { + if key.bind_group_data.has_albedo { + fragment.shader_defs.push("HAS_COLORS".into()); + } + if key.bind_group_data.has_emissive { + fragment.shader_defs.push("HAS_EMISSIVE_COLORS".into()); + } + } + Ok(()) + } +} + +pub fn add_particles_materials(mut commands: Commands, meshes: Query<(Entity, &UntypedMaterial)>) { + for (entity, handle) in meshes.iter() { + let handle = handle.deref().clone(); + if let Ok(handle) = handle.try_typed::() { + commands + .entity(entity) + .insert(MeshMaterial3d::(handle)); + } + } +} diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs new file mode 100644 index 00000000..38034d73 --- /dev/null +++ b/crates/processing_render/src/particles/mod.rs @@ -0,0 +1,589 @@ +//! See `docs/particles.md`. + +pub mod algebra; +pub mod compact; +mod emit; +pub mod grid; +pub mod kernels; +pub mod material; +pub mod pack; +pub mod point_render; +pub mod reduce; +pub mod scan; +mod scatter; +pub mod sort; + +pub use algebra::{ + GEN_GAUSSIAN, GEN_SIGNED, GEN_UNIFORM, MAP_ABS, MAP_AFFINE, MAP_CLAMP, MAP_EQ, MAP_FLOOR, + MAP_GEQ, MAP_GREATER, MAP_LEQ, MAP_LESS, MAP_NEGATE, MAP_NEQ, MAP_SQRT, MAP_SQUARE, + REDUCE_LENGTH, REDUCE_MAX, REDUCE_MEAN, REDUCE_MIN, REDUCE_SUM, REDUCE_SUMSQ, combine, extract, + generate, lookup, map, mix, pack, reduce_components, +}; +pub use compact::compact; +pub use emit::{ + particles_apply, particles_emit, particles_emit_gpu, particles_flock, particles_gather, +}; +pub use grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; +pub use kernels::{ + BOUNDS_CLAMP, BOUNDS_REFLECT, BOUNDS_SOFT, BOUNDS_WRAP, COMBINE_ADD, COMBINE_DIV, COMBINE_MAX, + COMBINE_MIN, COMBINE_MUL, COMBINE_POW, COMBINE_SUB, FALLOFF_CONST, FALLOFF_CUBIC, + FALLOFF_INVERSE, FALLOFF_LINEAR, FALLOFF_QUADRATIC, FALLOFF_SMOOTHSTEP, particles_kernel_age, + particles_kernel_attr_combine, particles_kernel_attr_linear, particles_kernel_attr_lookup1d, + particles_kernel_attr_lookup2d, particles_kernel_attr_mix, particles_kernel_attract, + particles_kernel_bounds_box, particles_kernel_bounds_geometry, particles_kernel_bounds_sphere, + particles_kernel_drag, particles_kernel_field, particles_kernel_flock, particles_kernel_force, + particles_kernel_impulse, particles_kernel_integrate, particles_kernel_noise, + particles_kernel_orient, particles_kernel_transform, particles_kernel_vortex, +}; +pub use reduce::{REDUCE_OP_MAX, REDUCE_OP_MIN, REDUCE_OP_SUM, reduce}; +pub use scan::prefix_sum_u32; +pub use scatter::{ + particles_scatter_create, particles_scatter_volume_create, prepare_scatter_source, + prepare_scatter_volume_source, +}; +pub use sort::bitonic_sort_by_key; + +use bevy::asset::RenderAssetUsages; +use bevy::mesh::{Indices, VertexAttributeValues}; +use bevy::pbr::gpu_instance_batch::GpuInstanceBatchPlugin; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::RenderApp; +use bevy::render::mesh::allocator::MeshAllocatorSettings; +use bevy::render::render_resource::{BufferDescriptor, BufferUsages}; +use bevy::render::renderer::RenderDevice; +use bevy::render::storage::ShaderBuffer; + +use processing_core::app_mut; +use processing_core::error::{self, ProcessingError, Result}; + +use crate::compute; +use crate::geometry::{Attribute, AttributeFormat, Geometry, default_attribute_init}; + +pub struct ParticlesPlugin; + +impl Plugin for ParticlesPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(GpuInstanceBatchPlugin); + app.add_plugins(pack::ParticlesPackPlugin); + app.add_plugins(material::ParticlesMaterialPlugin); + app.add_plugins(kernels::ParticlesKernelsPlugin); + app.add_plugins(point_render::ParticlesPointRenderPlugin); + } + + fn finish(&self, app: &mut App) { + // The mesh allocator emits its GPU buffers before the render device + // exists, so the STORAGE flag must be set in finish(), not build(). + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .world_mut() + .resource_mut::() + .extra_buffer_usages |= BufferUsages::STORAGE; + } +} + +#[derive(Component)] +pub struct Particles { + pub capacity: u32, + /// `Attribute` entity to backing `compute::Buffer` entity. + pub buffers: HashMap, + /// Must outlive the per-frame draw: `GpuInstanceBatchReservations` queues + /// mesh batches one frame behind, so respawning per-frame loses the reservation. + pub draw_entity: Option, + pub raster_draw_entity: Option, + pub connectivity: Option, + /// Ring-buffer write cursor; wraps at `capacity`. + pub emit_head: u32, +} + +#[derive(Clone, Copy)] +pub struct Connectivity { + pub index_buffer: Entity, + pub indirect_buffer: Entity, +} + +impl Particles { + pub fn buffer(&self, attribute: Entity) -> Option { + self.buffers.get(&attribute).copied() + } +} + +#[derive(Component, Clone, Copy)] +pub struct ParticlesDraw { + pub particles: Entity, +} + +pub fn create( + In((capacity, attribute_entities)): In<(u32, Vec)>, + mut commands: Commands, + attributes: Query<&Attribute>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let mut buffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let per_element = convention_seed_bytes(attr.name, attr.format); + let elem_size = attr.format.byte_size(); + if per_element.len() != elem_size { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{}' reuses a builtin name with an incompatible format; \ + declare it with an explicit default", + attr.name + ))); + } + let initial = tile_seed(&per_element, capacity as usize); + let buffer_entity = + make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); + buffers.insert(attr_entity, buffer_entity); + } + + let entity = commands + .spawn(Particles { + capacity, + buffers, + draw_entity: None, + raster_draw_entity: None, + connectivity: None, + emit_head: 0, + }) + .id(); + Ok(entity) +} + +pub fn create_from_geometry( + In((geom_entity, attribute_entities)): In<(Entity, Vec)>, + mut commands: Commands, + geometries: Query<&Geometry>, + attributes: Query<&Attribute>, + meshes: Res>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)?; + let capacity = mesh.count_vertices() as u32; + + let mut buffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let byte_size = capacity as u64 * attr.format.byte_size() as u64; + + let initial = mesh + .attribute(attr.inner) + .and_then(|values| attribute_values_to_bytes(values, attr.format)) + .filter(|bytes| bytes.len() == byte_size as usize) + .unwrap_or_else(|| { + let per_element = convention_seed_bytes(attr.name, attr.format); + if per_element.len() == attr.format.byte_size() { + tile_seed(&per_element, capacity as usize) + } else { + vec![0u8; byte_size as usize] + } + }); + + let buffer_entity = + make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); + buffers.insert(attr_entity, buffer_entity); + } + + let connectivity = mesh.indices().map(|indices| { + let index_data: Vec = match indices { + Indices::U16(v) => v.iter().map(|&i| i as u32).collect(), + Indices::U32(v) => v.clone(), + }; + let index_buffer = make_buffer_with_usage( + &mut commands, + &mut shader_buffers, + &render_device, + &u32s_to_bytes(&index_data), + BufferUsages::INDEX, + ); + let args = [index_data.len() as u32, 1, 0, 0, 0]; + let indirect_buffer = make_buffer_with_usage( + &mut commands, + &mut shader_buffers, + &render_device, + &u32s_to_bytes(&args), + BufferUsages::INDIRECT, + ); + Connectivity { + index_buffer, + indirect_buffer, + } + }); + + let entity = commands + .spawn(Particles { + capacity, + buffers, + draw_entity: None, + raster_draw_entity: None, + connectivity, + emit_head: 0, + }) + .id(); + Ok(entity) +} + +pub fn particles_set_connectivity( + particles_entity: Entity, + index_count: u32, +) -> error::Result { + use bevy::render::render_resource::BufferUsages; + + let index_buffer = + crate::buffer_create_with_usage(index_count as u64 * 4, BufferUsages::INDEX)?; + let indirect_buffer = + crate::buffer_create_with_usage(20, BufferUsages::INDIRECT | BufferUsages::STORAGE)?; + crate::buffer_write(indirect_buffer, u32s_to_bytes(&[index_count, 1, 0, 0, 0]))?; + + let previous = app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + Ok(field.connectivity.replace(Connectivity { + index_buffer, + indirect_buffer, + })) + })?; + if let Some(previous) = previous { + crate::buffer_destroy(previous.index_buffer)?; + crate::buffer_destroy(previous.indirect_buffer)?; + } + Ok(index_buffer) +} + +pub fn particles_connectivity_indirect(particles_entity: Entity) -> error::Result { + app_mut(|app| { + let field = app + .world() + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field + .connectivity + .as_ref() + .map(|c| c.indirect_buffer) + .ok_or_else(|| { + error::ProcessingError::InvalidArgument( + "particles have no index buffer; call index_buffer() first".to_string(), + ) + }) + }) +} + +pub fn particles_reset_indices(particles_entity: Entity) -> error::Result<()> { + let indirect = particles_connectivity_indirect(particles_entity)?; + crate::buffer_write(indirect, u32s_to_bytes(&[0, 1, 0, 0, 0])) +} + +fn make_buffer( + commands: &mut Commands, + shader_buffers: &mut Assets, + render_device: &RenderDevice, + initial: &[u8], +) -> Entity { + make_buffer_with_usage( + commands, + shader_buffers, + render_device, + initial, + BufferUsages::empty(), + ) +} + +fn make_buffer_with_usage( + commands: &mut Commands, + shader_buffers: &mut Assets, + render_device: &RenderDevice, + initial: &[u8], + extra_usage: BufferUsages, +) -> Entity { + let byte_size = initial.len() as u64; + let mut shader_buffer = ShaderBuffer::new(initial, RenderAssetUsages::all()); + shader_buffer.buffer_description.usage |= extra_usage; + let handle = shader_buffers.add(shader_buffer); + let readback = render_device.create_buffer(&BufferDescriptor { + label: Some("Particles Buffer Readback"), + size: byte_size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + commands + .spawn(compute::Buffer { + handle, + readback_buffer: readback, + size: byte_size, + synced: true, + bound_rw: false, + }) + .id() +} + +fn u32s_to_bytes(values: &[u32]) -> Vec { + let mut bytes = Vec::with_capacity(values.len() * 4); + for &value in values { + bytes.extend_from_slice(&value.to_le_bytes()); + } + bytes +} + +fn attribute_values_to_bytes( + values: &VertexAttributeValues, + format: AttributeFormat, +) -> Option> { + match (format, values) { + (AttributeFormat::Float, VertexAttributeValues::Float32(v)) => { + Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()) + } + (AttributeFormat::Float2, VertexAttributeValues::Float32x2(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float3, VertexAttributeValues::Float32x3(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float4, VertexAttributeValues::Float32x4(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + _ => None, + } +} + +pub fn destroy( + In(entity): In, + mut commands: Commands, + particles: Query<&Particles>, +) -> Result<()> { + let p = particles + .get(entity) + .map_err(|_| ProcessingError::ParticlesNotFound)?; + for &buffer_entity in p.buffers.values() { + commands.entity(buffer_entity).despawn(); + } + if let Some(draw_entity) = p.draw_entity { + commands.entity(draw_entity).despawn(); + } + if let Some(raster_draw_entity) = p.raster_draw_entity { + commands.entity(raster_draw_entity).despawn(); + } + if let Some(connectivity) = p.connectivity { + commands.entity(connectivity.index_buffer).despawn(); + commands.entity(connectivity.indirect_buffer).despawn(); + } + commands.entity(entity).despawn(); + Ok(()) +} + +pub enum AttributeSeed { + Ensure, + Declare(Option>), +} + +fn tile_seed(per_element: &[u8], capacity: usize) -> Vec { + let mut bytes = Vec::with_capacity(capacity * per_element.len()); + for _ in 0..capacity { + bytes.extend_from_slice(per_element); + } + bytes +} + +fn convention_seed_bytes(name: &str, format: AttributeFormat) -> Vec { + default_attribute_init(name, format) + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect() +} + +pub fn materialize_attribute( + In((particles_entity, attribute_entity, seed)): In<(Entity, Entity, AttributeSeed)>, + mut commands: Commands, + mut particles_q: Query<&mut Particles>, + attributes: Query<&Attribute>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let attr = attributes + .get(attribute_entity) + .map_err(|_| ProcessingError::InvalidEntity)? + .clone(); + + let capacity = { + let particles = particles_q + .get(particles_entity) + .map_err(|_| ProcessingError::ParticlesNotFound)?; + let existing = match particles.buffers.get(&attribute_entity).copied() { + Some(buf) => Some(buf), + None => { + let mut hit = None; + for (&e, &buf) in &particles.buffers { + let Ok(other) = attributes.get(e) else { + continue; + }; + if other.name == attr.name { + if other.format != attr.format { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{}' already present with a different format", + attr.name + ))); + } + hit = Some(buf); + break; + } + } + hit + } + }; + if let Some(buf) = existing { + return match seed { + AttributeSeed::Ensure => Ok(buf), + AttributeSeed::Declare(_) => Err(ProcessingError::InvalidArgument(format!( + "particles already have attribute '{}'", + attr.name + ))), + }; + } + particles.capacity as usize + }; + + let elem_size = attr.format.byte_size(); + let per_element = match &seed { + AttributeSeed::Declare(Some(default_bytes)) => { + if default_bytes.len() != elem_size { + return Err(ProcessingError::InvalidArgument(format!( + "default value byte size {} does not match attribute '{}' format byte size {}", + default_bytes.len(), + attr.name, + elem_size, + ))); + } + default_bytes.clone() + } + AttributeSeed::Ensure | AttributeSeed::Declare(None) => { + let seed = convention_seed_bytes(attr.name, attr.format); + if seed.len() != elem_size { + return Err(ProcessingError::InvalidArgument(format!( + "attribute '{}' reuses a builtin name with an incompatible format; \ + declare it with an explicit default", + attr.name + ))); + } + seed + } + }; + + let initial = tile_seed(&per_element, capacity); + let buffer_entity = make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); + particles_q + .get_mut(particles_entity) + .map_err(|_| ProcessingError::ParticlesNotFound)? + .buffers + .insert(attribute_entity, buffer_entity); + Ok(buffer_entity) +} + +pub fn particles_create(capacity: u32, attribute_entities: Vec) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(create, (capacity, attribute_entities)) + .unwrap() + }) +} + +pub fn particles_create_from_geometry( + geometry_entity: Entity, + attribute_entities: Vec, +) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(create_from_geometry, (geometry_entity, attribute_entities)) + .unwrap() + }) +} + +pub fn particles_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(destroy, entity) + .unwrap() + }) +} + +pub fn particles_capacity(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .capacity) + }) +} + +pub fn particles_buffer(entity: Entity, attribute_entity: Entity) -> error::Result> { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .buffer(attribute_entity)) + }) +} + +pub fn particles_ensure_attribute( + particles_entity: Entity, + attribute_entity: Entity, +) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + materialize_attribute, + (particles_entity, attribute_entity, AttributeSeed::Ensure), + ) + .unwrap() + }) +} + +pub fn particles_attribute_add( + particles_entity: Entity, + attribute_entity: Entity, + default: Option, +) -> error::Result<()> { + let default_bytes = match default { + None => None, + Some(v) => Some(v.to_bytes().ok_or_else(|| { + error::ProcessingError::InvalidArgument( + "default must be a scalar/vector ShaderValue, not a Buffer/Texture/Mesh*" + .to_string(), + ) + })?), + }; + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + materialize_attribute, + ( + particles_entity, + attribute_entity, + AttributeSeed::Declare(default_bytes), + ), + ) + .unwrap() + .map(|_| ()) + }) +} diff --git a/crates/processing_render/src/particles/pack.rs b/crates/processing_render/src/particles/pack.rs new file mode 100644 index 00000000..0d844113 --- /dev/null +++ b/crates/processing_render/src/particles/pack.rs @@ -0,0 +1,404 @@ +use std::num::NonZeroU64; + +use bevy::core_pipeline::Core3d; +use bevy::pbr::{ + MeshCullingDataBuffer, MeshInputUniform, MeshUniform, early_gpu_preprocess, + gpu_instance_batch::GpuInstanceBatchReservations, +}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::{ + Extract, ExtractSchedule, Render, RenderApp, RenderSystems, + batching::gpu_preprocessing::BatchedInstanceBuffers, + render_asset::RenderAssets, + render_resource::{ + BindGroup, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, + BufferBindingType, CachedComputePipelineId, CachedPipelineState, ComputePassDescriptor, + ComputePipelineDescriptor, PipelineCache, ShaderStages, ShaderType, UniformBuffer, + }, + renderer::{RenderContext, RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + sync_world::{MainEntity, MainEntityHashMap}, +}; +use bevy::shader::{Shader, ShaderDefVal}; + +use crate::compute; +use crate::geometry::BuiltinAttributes; + +use super::{Particles, ParticlesDraw}; + +const WORKGROUP_SIZE: u32 = 64; + +pub struct ParticlesPackPlugin; + +impl Plugin for ParticlesPackPlugin { + fn build(&self, app: &mut App) { + let shader = { + let mut shaders = app.world_mut().resource_mut::>(); + shaders.add(Shader::from_wgsl( + include_str!("pack.wgsl"), + "processing_render/particles/pack.wgsl", + )) + }; + app.insert_resource(ParticlesPackShader(shader.clone())); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(ParticlesPackShader(shader)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(ExtractSchedule, extract_particles_draws) + .add_systems( + Render, + prepare_pack_bind_groups.in_set(RenderSystems::PrepareBindGroups), + ) + .add_systems(Core3d, dispatch_pack.before(early_gpu_preprocess)); + } +} + +#[derive(Resource, Clone)] +pub struct ParticlesPackShader(pub Handle); + +#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)] +pub struct PackPipelineKey { + pub has_rotation: bool, + pub has_scale: bool, + pub has_life: bool, +} + +pub struct CachedPackPipeline { + pub bind_group_layout: BindGroupLayoutDescriptor, + pub pipeline: CachedComputePipelineId, +} + +#[derive(Resource, Default)] +pub struct ParticlesPackPipelines { + pub by_key: HashMap, +} + +#[derive(Copy, Clone, Default, ShaderType)] +struct ParticlesPackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +pub struct ExtractedParticlesData { + pub key: PackPipelineKey, + pub position: Handle, + pub rotation: Option>, + pub scale: Option>, + pub life: Option>, +} + +#[derive(Resource, Default)] +pub struct ExtractedParticlesDraws { + pub by_main: MainEntityHashMap, +} + +#[derive(Resource, Default)] +pub struct ParticlesPackBindGroups { + per_batch: MainEntityHashMap, +} + +struct PerBatchBindGroup { + bind_group: BindGroup, + pipeline: CachedComputePipelineId, + dispatch_count: u32, +} + +fn pack_layout_entries(key: PackPipelineKey) -> Vec { + let storage_rw = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let storage_r = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let uniform = BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(16), + }; + + let mut entries = vec![ + layout_entry(0, storage_rw), + layout_entry(1, storage_rw), + layout_entry(2, storage_r), + ]; + if key.has_rotation { + entries.push(layout_entry(3, storage_r)); + } + if key.has_scale { + entries.push(layout_entry(4, storage_r)); + } + if key.has_life { + entries.push(layout_entry(5, storage_r)); + } + entries.push(layout_entry(6, uniform)); + entries +} + +fn layout_entry(binding: u32, ty: BindingType) -> BindGroupLayoutEntry { + BindGroupLayoutEntry { + binding, + visibility: ShaderStages::COMPUTE, + ty, + count: None, + } +} + +fn shader_defs_for(key: PackPipelineKey) -> Vec { + let mut defs = Vec::new(); + if key.has_rotation { + defs.push("HAS_ROTATION".into()); + } + if key.has_scale { + defs.push("HAS_SCALE".into()); + } + if key.has_life { + defs.push("HAS_LIFE".into()); + } + defs +} + +fn get_or_create_pipeline( + pipelines: &mut ParticlesPackPipelines, + pipeline_cache: &PipelineCache, + shader: &Handle, + key: PackPipelineKey, +) -> CachedComputePipelineId { + if let Some(cached) = pipelines.by_key.get(&key) { + return cached.pipeline; + } + let bind_group_layout = BindGroupLayoutDescriptor::new( + format!( + "ParticlesPackBindGroupLayout(rot={},scale={},life={})", + key.has_rotation, key.has_scale, key.has_life + ), + &pack_layout_entries(key), + ); + let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some( + format!( + "particles_pack_pipeline(rot={},scale={},life={})", + key.has_rotation, key.has_scale, key.has_life + ) + .into(), + ), + layout: vec![bind_group_layout.clone()], + shader: shader.clone(), + shader_defs: shader_defs_for(key), + entry_point: Some("pack".into()), + ..default() + }); + pipelines.by_key.insert( + key, + CachedPackPipeline { + bind_group_layout, + pipeline, + }, + ); + pipelines.by_key.get(&key).unwrap().pipeline +} + +fn extract_particles_draws( + particles_draws: Extract>, + particles_q: Extract>, + buffers: Extract>, + builtins: Extract>, + mut extracted: ResMut, +) { + extracted.by_main.clear(); + for (entity, particles_draw) in particles_draws.iter() { + let Ok(p) = particles_q.get(particles_draw.particles) else { + continue; + }; + let Some(pos_entity) = p.buffer(builtins.position) else { + continue; + }; + let Ok(pos_buf) = buffers.get(pos_entity) else { + continue; + }; + let rotation = p + .buffer(builtins.rotation) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let scale = p + .buffer(builtins.scale) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let life = p + .buffer(builtins.life) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + + let key = PackPipelineKey { + has_rotation: rotation.is_some(), + has_scale: scale.is_some(), + has_life: life.is_some(), + }; + extracted.by_main.insert( + MainEntity::from(entity), + ExtractedParticlesData { + key, + position: pos_buf.handle.clone(), + rotation, + scale, + life, + }, + ); + } +} + +fn prepare_pack_bind_groups( + shader: Res, + mut pipelines: ResMut, + pipeline_cache: Res, + extracted: Res, + reservations: Res, + batched_instance_buffers: Res>, + culling_data_buffer: Res, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, + mut bind_groups: ResMut, +) { + bind_groups.per_batch.clear(); + + let Some(input_buffer) = batched_instance_buffers + .current_input_buffer + .buffer() + .buffer() + else { + return; + }; + let Some(culling_buffer) = culling_data_buffer.buffer() else { + return; + }; + + for (main_entity, data) in extracted.by_main.iter() { + let Some(reservation) = reservations.by_entity.get(main_entity) else { + continue; + }; + let Some(gpu_position) = gpu_buffers.get(&data.position) else { + continue; + }; + let gpu_rotation = data.rotation.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_rotation && gpu_rotation.is_none() { + continue; + } + let gpu_scale = data.scale.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_scale && gpu_scale.is_none() { + continue; + } + let gpu_life = data.life.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_life && gpu_life.is_none() { + continue; + } + + let pipeline_id = + get_or_create_pipeline(&mut pipelines, &pipeline_cache, &shader.0, data.key); + if !matches!( + pipeline_cache.get_compute_pipeline_state(pipeline_id), + CachedPipelineState::Ok(_) + ) { + continue; + } + let cached = pipelines.by_key.get(&data.key).unwrap(); + + let params = ParticlesPackParams { + base_input_index: reservation.input_buffer_base, + count: reservation.max_capacity, + ..default() + }; + let mut uniform = UniformBuffer::from(params); + uniform.write_buffer(&render_device, &render_queue); + + let mut entries: Vec = vec![ + BindGroupEntry { + binding: 0, + resource: input_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 1, + resource: culling_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 2, + resource: gpu_position.buffer.as_entire_binding(), + }, + ]; + if let Some(gpu_rotation) = gpu_rotation { + entries.push(BindGroupEntry { + binding: 3, + resource: gpu_rotation.buffer.as_entire_binding(), + }); + } + if let Some(gpu_scale) = gpu_scale { + entries.push(BindGroupEntry { + binding: 4, + resource: gpu_scale.buffer.as_entire_binding(), + }); + } + if let Some(gpu_life) = gpu_life { + entries.push(BindGroupEntry { + binding: 5, + resource: gpu_life.buffer.as_entire_binding(), + }); + } + entries.push(BindGroupEntry { + binding: 6, + resource: uniform.binding().unwrap(), + }); + + let bind_group = render_device.create_bind_group( + Some("particles_pack_bind_group"), + &pipeline_cache.get_bind_group_layout(&cached.bind_group_layout), + &entries, + ); + + let dispatch_count = reservation.max_capacity.div_ceil(WORKGROUP_SIZE); + bind_groups.per_batch.insert( + *main_entity, + PerBatchBindGroup { + bind_group, + pipeline: pipeline_id, + dispatch_count, + }, + ); + } +} + +fn dispatch_pack( + mut render_context: RenderContext, + bind_groups: Res, + pipeline_cache: Res, +) { + if bind_groups.per_batch.is_empty() { + return; + } + + let mut pass = render_context + .command_encoder() + .begin_compute_pass(&ComputePassDescriptor { + label: Some("particles_pack"), + timestamp_writes: None, + }); + + for per_batch in bind_groups.per_batch.values() { + let Some(compute_pipeline) = pipeline_cache.get_compute_pipeline(per_batch.pipeline) else { + continue; + }; + pass.set_pipeline(compute_pipeline); + pass.set_bind_group(0, &per_batch.bind_group, &[]); + pass.dispatch_workgroups(per_batch.dispatch_count, 1, 1); + } +} diff --git a/crates/processing_render/src/particles/pack.wgsl b/crates/processing_render/src/particles/pack.wgsl new file mode 100644 index 00000000..c1b0e460 --- /dev/null +++ b/crates/processing_render/src/particles/pack.wgsl @@ -0,0 +1,113 @@ +struct MeshInput { + world_from_local: mat3x4, + lightmap_uv_rect: vec2, + flags: u32, + previous_input_index: u32, + first_vertex_index: u32, + first_index_index: u32, + index_count: u32, + current_skin_index: u32, + material_and_lightmap_bind_group_slot: u32, + timestamp: u32, + tag: u32, + morph_descriptor_index: u32, +} + +struct MeshCullingData { + aabb_center: vec3, + _pad: f32, + aabb_half_extents: vec3, + life: f32, +} + +struct PackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +@group(0) @binding(0) var mesh_input_buffer: array; +@group(0) @binding(1) var mesh_culling_buffer: array; +@group(0) @binding(2) var position: array; +#ifdef HAS_ROTATION +@group(0) @binding(3) var rotation: array; +#endif +#ifdef HAS_SCALE +@group(0) @binding(4) var scale: array; +#endif +#ifdef HAS_LIFE +@group(0) @binding(5) var life: array; +#endif +@group(0) @binding(6) var params: PackParams; + +fn quat_to_basis(q: vec4) -> mat3x3 { + let x = q.x; let y = q.y; let z = q.z; let w = q.w; + let xx = x * x; let yy = y * y; let zz = z * z; + let xy = x * y; let xz = x * z; let yz = y * z; + let wx = w * x; let wy = w * y; let wz = w * z; + return mat3x3( + vec3(1.0 - 2.0 * (yy + zz), 2.0 * (xy + wz), 2.0 * (xz - wy)), + vec3(2.0 * (xy - wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz + wx)), + vec3(2.0 * (xz + wy), 2.0 * (yz - wx), 1.0 - 2.0 * (xx + yy)), + ); +} + +@compute @workgroup_size(64) +fn pack(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= params.count { + return; + } + let slot = params.base_input_index + i; + + let pos = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + +#ifdef HAS_ROTATION + let q = vec4( + rotation[i * 4u + 0u], + rotation[i * 4u + 1u], + rotation[i * 4u + 2u], + rotation[i * 4u + 3u], + ); + let basis = quat_to_basis(q); +#else + let basis = mat3x3( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), + ); +#endif + +#ifdef HAS_SCALE + let s = vec3( + scale[i * 3u + 0u], + scale[i * 3u + 1u], + scale[i * 3u + 2u], + ); +#else + let s = vec3(1.0, 1.0, 1.0); +#endif + + let c0 = basis[0] * s.x; + let c1 = basis[1] * s.y; + let c2 = basis[2] * s.z; + mesh_input_buffer[slot].world_from_local = mat3x4( + vec4(c0.x, c1.x, c2.x, pos.x), + vec4(c0.y, c1.y, c2.y, pos.y), + vec4(c0.z, c1.z, c2.z, pos.z), + ); + mesh_input_buffer[slot].tag = i; + + mesh_culling_buffer[slot].aabb_center = vec3(0.0, 0.0, 0.0); + mesh_culling_buffer[slot].aabb_half_extents = vec3(1.0, 1.0, 1.0); +#ifdef HAS_LIFE + mesh_culling_buffer[slot].life = life[i]; +#else + mesh_culling_buffer[slot].life = 1.0; +#endif +} diff --git a/crates/processing_render/src/particles/particles.wgsl b/crates/processing_render/src/particles/particles.wgsl new file mode 100644 index 00000000..05a7419d --- /dev/null +++ b/crates/processing_render/src/particles/particles.wgsl @@ -0,0 +1,60 @@ +#import bevy_pbr::{ + pbr_fragment::pbr_input_from_standard_material, + pbr_functions::alpha_discard, + mesh_functions, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, +} +#endif + +#ifdef HAS_COLORS +@group(#{MATERIAL_BIND_GROUP}) @binding(100) +var particle_colors: array>; +#endif + +#ifdef HAS_EMISSIVE_COLORS +@group(#{MATERIAL_BIND_GROUP}) @binding(101) +var particle_emissive_colors: array>; +#endif + +@fragment +fn fragment( + in: VertexOutput, + @builtin(front_facing) is_front: bool, +) -> FragmentOutput { + var pbr_input = pbr_input_from_standard_material(in, is_front); + + let tag = mesh_functions::get_tag(in.instance_index); + +#ifdef HAS_COLORS + pbr_input.material.base_color = pbr_input.material.base_color * particle_colors[tag]; +#endif + +#ifdef HAS_EMISSIVE_COLORS + pbr_input.material.emissive = vec4( + pbr_input.material.emissive.rgb + particle_emissive_colors[tag].rgb, + pbr_input.material.emissive.a + ); +#endif + + pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + +#ifdef PREPASS_PIPELINE + let out = deferred_output(in, pbr_input); +#else + var out: FragmentOutput; + out.color = apply_pbr_lighting(pbr_input); + out.color = main_pass_post_lighting_processing(pbr_input, out.color); +#endif + + return out; +} diff --git a/crates/processing_render/src/particles/point.wgsl b/crates/processing_render/src/particles/point.wgsl new file mode 100644 index 00000000..14d418ed --- /dev/null +++ b/crates/processing_render/src/particles/point.wgsl @@ -0,0 +1,55 @@ +#import bevy_render::view::View + +@group(0) @binding(0) var view: View; +@group(1) @binding(0) var positions: array; +#ifdef HAS_COLORS +@group(1) @binding(1) var colors: array; +#endif +#ifdef HAS_NORMALS +@group(1) @binding(2) var normals: array; +#endif + +struct VertexOutput { + @builtin(position) clip_position: vec4, + @location(0) world: vec3, + @location(1) color: vec4, + @location(2) normal: vec3, +} + +@vertex +fn vertex(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { + let i = vertex_index; + let world = vec3(positions[i * 3u], positions[i * 3u + 1u], positions[i * 3u + 2u]); + + var out: VertexOutput; + out.world = world; + out.clip_position = view.clip_from_world * vec4(world, 1.0); +#ifdef HAS_COLORS + out.color = vec4( + colors[i * 4u], colors[i * 4u + 1u], colors[i * 4u + 2u], colors[i * 4u + 3u]); +#else + out.color = vec4(1.0, 1.0, 1.0, 1.0); +#endif +#ifdef HAS_NORMALS + out.normal = vec3(normals[i * 3u], normals[i * 3u + 1u], normals[i * 3u + 2u]); +#else + out.normal = vec3(0.0, 0.0, 0.0); +#endif + return out; +} + +@fragment +fn fragment(frag: VertexOutput) -> @location(0) vec4 { + var rgb = frag.color.rgb; +#ifdef SHADED + #ifdef HAS_NORMALS + let normal = normalize(frag.normal); + #else + let normal = normalize(cross(dpdx(frag.world), dpdy(frag.world))); + #endif + let light = normalize(vec3(0.4, 0.85, 0.35)); + let diffuse = abs(dot(normal, light)); + rgb = rgb * (0.2 + 0.8 * diffuse); +#endif + return vec4(rgb, frag.color.a); +} diff --git a/crates/processing_render/src/particles/point_render.rs b/crates/processing_render/src/particles/point_render.rs new file mode 100644 index 00000000..2e8db841 --- /dev/null +++ b/crates/processing_render/src/particles/point_render.rs @@ -0,0 +1,482 @@ +use bevy::asset::embedded_asset; +use bevy::camera::visibility::{self, VisibilityClass}; +use bevy::core_pipeline::core_3d::{ + CORE_3D_DEPTH_FORMAT, Opaque3d, Opaque3dBatchSetKey, Opaque3dBinKey, Transparent3d, + TransparentSortingInfo3d, +}; +use bevy::ecs::query::ROQueryItem; +use bevy::ecs::system::SystemParamItem; +use bevy::ecs::system::lifetimeless::{Read, SRes}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::extract_component::{ExtractComponent, ExtractComponentPlugin}; +use bevy::render::mesh::allocator::MeshSlabs; +use bevy::render::render_asset::RenderAssets; +use bevy::render::render_phase::{ + AddRenderCommand, BinnedRenderPhaseType, DrawFunctions, InputUniformIndex, PhaseItem, + PhaseItemExtraIndex, RenderCommand, RenderCommandResult, SetItemPipeline, TrackedRenderPass, + ViewBinnedRenderPhases, ViewSortedRenderPhases, +}; +use bevy::render::render_resource::binding_types::{ + storage_buffer_read_only_sized, uniform_buffer, +}; +use bevy::render::render_resource::{ + BindGroup, BindGroupEntries, BindGroupLayout, BindGroupLayoutDescriptor, + BindGroupLayoutEntries, BlendState, Buffer, Canonical, ColorTargetState, ColorWrites, + CompareFunction, DepthStencilState, FragmentState, IndexFormat, PipelineCache, PrimitiveState, + PrimitiveTopology, RenderPipeline, RenderPipelineDescriptor, ShaderStages, Specializer, + SpecializerKey, TextureFormat, Variants, VertexState, +}; +use bevy::render::renderer::RenderDevice; +use bevy::render::storage::GpuShaderBuffer; +use bevy::render::sync_world::MainEntity; +use bevy::render::view::{ + ExtractedView, RenderVisibleEntities, ViewUniform, ViewUniformOffset, ViewUniforms, +}; +use bevy::render::{Render, RenderApp, RenderSystems}; +use bevy::shader::Shader; + +use bevy::render::storage::ShaderBuffer; + +use crate::geometry::Topology; + +const SURFACE_FORMAT: TextureFormat = TextureFormat::Rgba16Float; + +pub struct ParticlesPointRenderPlugin; + +impl Plugin for ParticlesPointRenderPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "point.wgsl"); + app.add_plugins(ExtractComponentPlugin::::default()); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .init_resource::() + .add_render_command::() + .add_render_command::() + .add_systems( + Render, + prepare_raster_bind_groups.in_set(RenderSystems::PrepareBindGroups), + ) + .add_systems(Render, queue_particle_raster.in_set(RenderSystems::Queue)); + } + + fn finish(&self, app: &mut App) { + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.init_resource::(); + } + } +} + +#[derive(Component, Clone, ExtractComponent)] +#[require(VisibilityClass)] +#[component(on_add = visibility::add_visibility_class::)] +pub struct ParticleRasterDraw { + pub position: Handle, + pub count: u32, + pub topology: Topology, + pub index: Option>, + pub indirect: Option>, + pub color: Option>, + pub normal: Option>, + /// The sketch's blend mode at draw time (`None` = opaque). + pub blend: Option, +} + +#[derive(Resource)] +struct ParticleRasterPipeline { + view_layout: BindGroupLayout, + storage_layout: BindGroupLayout, + variants: Variants, +} + +struct RasterSpecializer; + +#[derive(Copy, Clone, PartialEq, Eq, Hash, SpecializerKey)] +struct RasterKey { + samples: u32, + topology: u8, + has_color: bool, + has_normal: bool, + blend: Option, + format: TextureFormat, +} + +impl Specializer for RasterSpecializer { + type Key = RasterKey; + fn specialize( + &self, + key: Self::Key, + descriptor: &mut RenderPipelineDescriptor, + ) -> Result, BevyError> { + descriptor.multisample.count = key.samples; + let topology = Topology::from_u8(key.topology).unwrap_or(Topology::PointList); + descriptor.primitive.topology = topology.to_primitive_topology(); + + let mut defs: Vec<&str> = Vec::new(); + if key.has_color { + defs.push("HAS_COLORS"); + } + if key.has_normal { + defs.push("HAS_NORMALS"); + } + if matches!(topology, Topology::TriangleList | Topology::TriangleStrip) { + defs.push("SHADED"); + } + for def in defs { + descriptor.vertex.shader_defs.push(def.into()); + if let Some(fragment) = descriptor.fragment.as_mut() { + fragment.shader_defs.push(def.into()); + } + } + + if let Some(target) = descriptor + .fragment + .as_mut() + .and_then(|f| f.targets.get_mut(0)) + .and_then(|t| t.as_mut()) + { + target.format = key.format; + target.blend = key.blend; + } + if key.blend.is_some() { + if let Some(depth) = descriptor.depth_stencil.as_mut() { + depth.depth_write_enabled = Some(false); + } + } + Ok(key) + } +} + +impl FromWorld for ParticleRasterPipeline { + fn from_world(world: &mut World) -> Self { + let render_device = world.resource::().clone(); + let asset_server = world.resource::(); + let shader: Handle = + asset_server.load("embedded://processing_render/particles/point.wgsl"); + + let view_entries: Vec<_> = BindGroupLayoutEntries::single( + ShaderStages::VERTEX, + uniform_buffer::(true), + ) + .to_vec(); + let view_layout = + render_device.create_bind_group_layout("point_view_layout", &view_entries); + let storage_entries: Vec<_> = BindGroupLayoutEntries::sequential( + ShaderStages::VERTEX, + ( + storage_buffer_read_only_sized(false, None), + storage_buffer_read_only_sized(false, None), + storage_buffer_read_only_sized(false, None), + ), + ) + .to_vec(); + let storage_layout = + render_device.create_bind_group_layout("point_storage_layout", &storage_entries); + + let base_descriptor = RenderPipelineDescriptor { + label: Some("particle_raster_pipeline".into()), + layout: vec![ + BindGroupLayoutDescriptor { + label: "point_view_layout".into(), + entries: view_entries, + }, + BindGroupLayoutDescriptor { + label: "point_storage_layout".into(), + entries: storage_entries, + }, + ], + vertex: VertexState { + shader: shader.clone(), + entry_point: Some("vertex".into()), + buffers: vec![], + ..default() + }, + fragment: Some(FragmentState { + shader, + entry_point: Some("fragment".into()), + targets: vec![Some(ColorTargetState { + format: SURFACE_FORMAT, + blend: None, + write_mask: ColorWrites::ALL, + })], + ..default() + }), + primitive: PrimitiveState { + topology: PrimitiveTopology::PointList, + ..default() + }, + depth_stencil: Some(DepthStencilState { + format: CORE_3D_DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(CompareFunction::GreaterEqual), + stencil: default(), + bias: default(), + }), + ..default() + }; + + Self { + view_layout, + storage_layout, + variants: Variants::new(RasterSpecializer, base_descriptor), + } + } +} + +struct RasterEntry { + storage_bg: BindGroup, + count: u32, + index: Option, + indirect: Option, +} + +#[derive(Resource, Default)] +struct RasterBindGroups { + view: Option, + entries: HashMap, +} + +fn prepare_raster_bind_groups( + render_device: Res, + pipeline: Res, + view_uniforms: Res, + gpu_buffers: Res>, + draws: Query<(&MainEntity, &ParticleRasterDraw)>, + mut bind_groups: ResMut, +) { + bind_groups.view = view_uniforms.uniforms.binding().map(|binding| { + render_device.create_bind_group( + "point_view_bind_group", + &pipeline.view_layout, + &BindGroupEntries::single(binding), + ) + }); + + bind_groups.entries.clear(); + for (main_entity, draw) in draws.iter() { + let Some(position) = gpu_buffers.get(&draw.position) else { + continue; + }; + let (index, indirect) = match (&draw.index, &draw.indirect) { + (Some(index_handle), Some(indirect_handle)) => { + let (Some(index_gpu), Some(indirect_gpu)) = ( + gpu_buffers.get(index_handle), + gpu_buffers.get(indirect_handle), + ) else { + continue; + }; + ( + Some(index_gpu.buffer.clone()), + Some(indirect_gpu.buffer.clone()), + ) + } + _ => (None, None), + }; + let color_buffer = match &draw.color { + Some(handle) => match gpu_buffers.get(handle) { + Some(gpu) => &gpu.buffer, + None => continue, + }, + None => &position.buffer, + }; + let normal_buffer = match &draw.normal { + Some(handle) => match gpu_buffers.get(handle) { + Some(gpu) => &gpu.buffer, + None => continue, + }, + None => &position.buffer, + }; + let storage_bg = render_device.create_bind_group( + "point_storage_bind_group", + &pipeline.storage_layout, + &BindGroupEntries::sequential(( + position.buffer.as_entire_binding(), + color_buffer.as_entire_binding(), + normal_buffer.as_entire_binding(), + )), + ); + bind_groups.entries.insert( + *main_entity, + RasterEntry { + storage_bg, + count: draw.count, + index, + indirect, + }, + ); + } +} + +fn queue_particle_raster( + pipeline_cache: Res, + mut pipeline: ResMut, + mut opaque_phases: ResMut>, + mut transparent_phases: ResMut>, + opaque_draw_functions: Res>, + transparent_draw_functions: Res>, + views: Query<(&ExtractedView, &RenderVisibleEntities, &Msaa)>, + raster_draws: Query<&ParticleRasterDraw>, +) { + let opaque_draw = opaque_draw_functions + .read() + .id::(); + let transparent_draw = transparent_draw_functions + .read() + .id::(); + + for (view, visible, msaa) in views.iter() { + let Some(visible_raster) = visible.get::() else { + continue; + }; + let mut opaque = opaque_phases.get_mut(&view.retained_view_entity); + let mut transparent = transparent_phases.get_mut(&view.retained_view_entity); + + if let Some(op) = opaque.as_deref_mut() { + for (_, main_entity) in &visible_raster.removed_entities { + op.remove(*main_entity); + } + } + for (render_entity, main_entity) in visible_raster.iter_visible() { + if let Some(op) = opaque.as_deref_mut() { + op.remove(*main_entity); + } + + let draw = raster_draws.get(*render_entity).ok(); + let topology = draw.map(|d| d.topology).unwrap_or(Topology::PointList); + let blended = draw.is_some_and(|d| d.blend.is_some()); + let indexed = draw.is_some_and(|d| d.index.is_some()); + + let Ok(pipeline_id) = pipeline.variants.specialize( + &pipeline_cache, + RasterKey { + samples: msaa.samples(), + topology: topology as u8, + has_color: draw.is_some_and(|d| d.color.is_some()), + has_normal: draw.is_some_and(|d| d.normal.is_some()), + blend: draw.and_then(|d| d.blend), + format: view.target_format, + }, + ) else { + continue; + }; + + if blended { + if let Some(tp) = transparent.as_deref_mut() { + tp.add_transient(Transparent3d { + // Sort by the view depth of the particle field's origin. + // NOT AlwaysOnTop: its -inf key sorts BEFORE the + // background quad (whose z-layer distance is a large + // negative batch offset), which painted the background + // over the particles. + sorting_info: TransparentSortingInfo3d::Sorted { + mesh_center: Vec3::ZERO, + depth_bias: 0.0, + }, + distance: 0.0, + pipeline: pipeline_id, + entity: (*render_entity, *main_entity), + draw_function: transparent_draw, + batch_range: 0..1, + extra_index: PhaseItemExtraIndex::None, + indexed, + }); + } + } else if let Some(op) = opaque.as_deref_mut() { + op.add( + Opaque3dBatchSetKey { + draw_function: opaque_draw, + pipeline: pipeline_id, + material_bind_group_index: None, + lightmap_slab: None, + slabs: MeshSlabs::default(), + }, + Opaque3dBinKey { + asset_id: AssetId::::invalid().untyped(), + }, + (*render_entity, *main_entity), + InputUniformIndex::default(), + BinnedRenderPhaseType::NonMesh, + ); + } + } + } +} + +type DrawParticleRasterCommands = ( + SetItemPipeline, + SetRasterViewBindGroup<0>, + SetRasterStorageBindGroup<1>, + DrawParticleRaster, +); + +struct SetRasterViewBindGroup; +impl RenderCommand

for SetRasterViewBindGroup { + type Param = SRes; + type ViewQuery = Read; + type ItemQuery = (); + + fn render<'w>( + _item: &P, + view_offset: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(view_bg) = bind_groups.into_inner().view.as_ref() else { + return RenderCommandResult::Skip; + }; + pass.set_bind_group(I, view_bg, &[view_offset.offset]); + RenderCommandResult::Success + } +} + +struct SetRasterStorageBindGroup; +impl RenderCommand

for SetRasterStorageBindGroup { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = (); + + fn render<'w>( + item: &P, + _view: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(entry) = bind_groups.into_inner().entries.get(&item.main_entity()) else { + return RenderCommandResult::Skip; + }; + pass.set_bind_group(I, &entry.storage_bg, &[]); + RenderCommandResult::Success + } +} + +struct DrawParticleRaster; +impl RenderCommand

for DrawParticleRaster { + type Param = SRes; + type ViewQuery = (); + type ItemQuery = (); + + fn render<'w>( + item: &P, + _view: ROQueryItem<'w, '_, Self::ViewQuery>, + _entity: Option>, + bind_groups: SystemParamItem<'w, '_, Self::Param>, + pass: &mut TrackedRenderPass<'w>, + ) -> RenderCommandResult { + let Some(entry) = bind_groups.into_inner().entries.get(&item.main_entity()) else { + return RenderCommandResult::Skip; + }; + match (&entry.index, &entry.indirect) { + (Some(index), Some(indirect)) => { + pass.set_index_buffer(index.slice(..), IndexFormat::Uint32); + pass.draw_indexed_indirect(indirect, 0); + } + _ => pass.draw(0..entry.count, 0..1), + } + RenderCommandResult::Success + } +} diff --git a/crates/processing_render/src/particles/reduce.rs b/crates/processing_render/src/particles/reduce.rs new file mode 100644 index 00000000..f86730c6 --- /dev/null +++ b/crates/processing_render/src/particles/reduce.rs @@ -0,0 +1,83 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_read_element, buffer_size, compute_create, + compute_dispatch, compute_set, shader_load, +}; + +const BLOCK: u32 = 256; +const SHADER: &str = "embedded://processing_render/particles/kernels/reduce.wgsl"; + +pub const REDUCE_OP_SUM: u32 = 0; +pub const REDUCE_OP_MIN: u32 = 1; +pub const REDUCE_OP_MAX: u32 = 2; + +static COMPUTE: Mutex> = Mutex::new(None); +static SCRATCH: Mutex> = Mutex::new(Vec::new()); + +fn reduce_compute() -> Result { + let mut guard = COMPUTE.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = compute_create(shader_load(SHADER)?)?; + *guard = Some(compute); + Ok(compute) +} + +fn scratch(level: usize, bytes: u64) -> Result { + let mut scratch = SCRATCH.lock().unwrap(); + while scratch.len() <= level { + scratch.push((Entity::PLACEHOLDER, 0)); + } + let (entity, size) = scratch[level]; + if entity != Entity::PLACEHOLDER && size >= bytes { + return Ok(entity); + } + if entity != Entity::PLACEHOLDER { + let _ = buffer_destroy(entity); + } + let new_entity = buffer_create(bytes)?; + scratch[level] = (new_entity, bytes); + Ok(new_entity) +} + +pub fn reduce(values: Entity, op: u32) -> Result { + let n = (buffer_size(values)? / 4) as u32; + if n == 0 { + return Ok(match op { + REDUCE_OP_MIN => f32::INFINITY, + REDUCE_OP_MAX => f32::NEG_INFINITY, + _ => 0.0, + }); + } + + let compute = reduce_compute()?; + let mut cur = values; + let mut cur_n = n; + let mut level = 0usize; + + loop { + let num_wg = cur_n.div_ceil(BLOCK); + let out = scratch(level, (num_wg as u64) * 4)?; + + compute_set(compute, "input", ShaderValue::Buffer(cur))?; + compute_set(compute, "output", ShaderValue::Buffer(out))?; + compute_set(compute, "mode", ShaderValue::UInt(op))?; + compute_set(compute, "count", ShaderValue::UInt(cur_n))?; + compute_dispatch(compute, num_wg, 1, 1)?; + + if num_wg == 1 { + let bytes = buffer_read_element(out, 0, 4)?; + return Ok(f32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])); + } + cur = out; + cur_n = num_wg; + level += 1; + } +} diff --git a/crates/processing_render/src/particles/scan.rs b/crates/processing_render/src/particles/scan.rs new file mode 100644 index 00000000..bab519e7 --- /dev/null +++ b/crates/processing_render/src/particles/scan.rs @@ -0,0 +1,89 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::Result; + +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create, buffer_destroy, buffer_size, compute_create, compute_dispatch, compute_set, + shader_load, +}; + +const BLOCK: u64 = 256; + +const BLOCK_SHADER: &str = "embedded://processing_render/particles/kernels/scan_block.wgsl"; +const ADD_SHADER: &str = "embedded://processing_render/particles/kernels/scan_add.wgsl"; + +static SCAN_COMPUTES: Mutex> = Mutex::new(None); + +static SCRATCH: Mutex> = Mutex::new(Vec::new()); + +fn scan_computes() -> Result<(Entity, Entity)> { + let mut guard = SCAN_COMPUTES.lock().unwrap(); + if let Some(v) = *guard { + return Ok(v); + } + let block = compute_create(shader_load(BLOCK_SHADER)?)?; + let add = compute_create(shader_load(ADD_SHADER)?)?; + *guard = Some((block, add)); + Ok((block, add)) +} + +fn scratch_buffer(level: usize, bytes: u64) -> Result { + let mut scratch = SCRATCH.lock().unwrap(); + while scratch.len() <= level { + scratch.push((Entity::PLACEHOLDER, 0)); + } + let (entity, size) = scratch[level]; + if entity != Entity::PLACEHOLDER && size >= bytes { + return Ok(entity); + } + if entity != Entity::PLACEHOLDER { + let _ = buffer_destroy(entity); + } + let new_entity = buffer_create(bytes)?; + scratch[level] = (new_entity, bytes); + Ok(new_entity) +} + +pub fn prefix_sum_u32(buffer: Entity) -> Result<()> { + let total_bytes = buffer_size(buffer)?; + if total_bytes == 0 { + return Ok(()); + } + + let (block, add) = scan_computes()?; + + let n = (total_bytes / 4).max(1); + + let mut level_bufs: Vec = vec![buffer]; + let mut level_ns: Vec = vec![n]; + + let mut lvl = 0usize; + loop { + let num_blocks = level_ns[lvl].div_ceil(BLOCK).max(1); + let sums = scratch_buffer(lvl, num_blocks * 4)?; + + compute_set(block, "data", ShaderValue::Buffer(level_bufs[lvl]))?; + compute_set(block, "block_sums", ShaderValue::Buffer(sums))?; + compute_dispatch(block, num_blocks as u32, 1, 1)?; + + level_bufs.push(sums); + level_ns.push(num_blocks); + + if num_blocks == 1 { + break; + } + lvl += 1; + } + + for k in (0..level_bufs.len() - 2).rev() { + let num_blocks = level_ns[k].div_ceil(BLOCK).max(1); + compute_set(add, "data", ShaderValue::Buffer(level_bufs[k]))?; + compute_set(add, "block_sums", ShaderValue::Buffer(level_bufs[k + 1]))?; + compute_dispatch(add, num_blocks as u32, 1, 1)?; + } + + Ok(()) +} diff --git a/crates/processing_render/src/particles/scatter.rs b/crates/processing_render/src/particles/scatter.rs new file mode 100644 index 00000000..f42c5b64 --- /dev/null +++ b/crates/processing_render/src/particles/scatter.rs @@ -0,0 +1,188 @@ +use bevy::mesh::{Indices, VertexAttributeValues}; +use bevy::prelude::*; + +use processing_core::app_mut; +use processing_core::error::{self, ProcessingError, Result}; + +use crate::geometry::Geometry; +use crate::shader_value::ShaderValue; +use crate::{ + buffer_create_with_data, compute_create, compute_set, geometry_attribute_position, shader_load, +}; + +pub fn particles_scatter_create(source_geometry: Entity) -> error::Result { + let (cdf_bytes, indices_bytes, face_count) = app_mut(|app| { + app.world_mut() + .run_system_cached_with(prepare_scatter_source, source_geometry) + .unwrap() + })?; + + let cdf_buf = buffer_create_with_data(cdf_bytes)?; + let idx_buf = buffer_create_with_data(indices_bytes)?; + + let shader = + shader_load("embedded://processing_render/particles/kernels/scatter_surface.wgsl")?; + let scatter = compute_create(shader)?; + + let position_attr = geometry_attribute_position(); + compute_set( + scatter, + "source_position", + ShaderValue::MeshAttribute(source_geometry, position_attr), + )?; + compute_set(scatter, "source_indices", ShaderValue::Buffer(idx_buf))?; + compute_set(scatter, "cdf", ShaderValue::Buffer(cdf_buf))?; + compute_set(scatter, "face_count", ShaderValue::UInt(face_count))?; + compute_set(scatter, "seed", ShaderValue::UInt(0xc0ffeeu32))?; + + Ok(scatter) +} + +pub fn particles_scatter_volume_create(source_geometry: Entity) -> error::Result { + let (indices_bytes, aabb_min, aabb_max, face_count) = app_mut(|app| { + app.world_mut() + .run_system_cached_with(prepare_scatter_volume_source, source_geometry) + .unwrap() + })?; + + let idx_buf = buffer_create_with_data(indices_bytes)?; + + let shader = shader_load("embedded://processing_render/particles/kernels/scatter_volume.wgsl")?; + let scatter = compute_create(shader)?; + + let position_attr = geometry_attribute_position(); + compute_set( + scatter, + "source_position", + ShaderValue::MeshAttribute(source_geometry, position_attr), + )?; + compute_set(scatter, "source_indices", ShaderValue::Buffer(idx_buf))?; + compute_set( + scatter, + "aabb_min", + ShaderValue::Float4([aabb_min[0], aabb_min[1], aabb_min[2], 0.0]), + )?; + compute_set( + scatter, + "aabb_max", + ShaderValue::Float4([aabb_max[0], aabb_max[1], aabb_max[2], 0.0]), + )?; + compute_set(scatter, "face_count", ShaderValue::UInt(face_count))?; + compute_set(scatter, "max_attempts", ShaderValue::UInt(32))?; + compute_set(scatter, "seed", ShaderValue::UInt(0xc0ffeeu32))?; + + Ok(scatter) +} + +fn extract_scatter_geometry(mesh: &mut Mesh) -> Result<(Vec<[f32; 3]>, Vec)> { + mesh.deinterleave(); + + let positions = match mesh.attribute(Mesh::ATTRIBUTE_POSITION) { + Some(VertexAttributeValues::Float32x3(p)) => p.clone(), + _ => { + return Err(ProcessingError::InvalidArgument( + "scatter source mesh has no Float32x3 position attribute".to_string(), + )); + } + }; + + let dense_indices: Vec = match mesh.indices() { + Some(Indices::U16(v)) => v.iter().map(|&i| i as u32).collect(), + Some(Indices::U32(v)) => v.clone(), + None => { + if positions.len() % 3 != 0 { + return Err(ProcessingError::InvalidArgument( + "scatter source mesh has no indices and a vertex count that isn't a \ + multiple of 3" + .to_string(), + )); + } + (0..positions.len() as u32).collect() + } + }; + + if dense_indices.len() % 3 != 0 { + return Err(ProcessingError::InvalidArgument( + "scatter source mesh has a non-triangle index list".to_string(), + )); + } + if dense_indices.is_empty() { + return Err(ProcessingError::InvalidArgument( + "scatter source mesh has no triangles".to_string(), + )); + } + + Ok((positions, dense_indices)) +} + +pub fn prepare_scatter_source( + In(geom_entity): In, + geometries: Query<&Geometry>, + mut meshes: ResMut>, +) -> Result<(Vec, Vec, u32)> { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get_mut(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)? + .into_inner(); + + let (positions, dense_indices) = extract_scatter_geometry(mesh)?; + let face_count = (dense_indices.len() / 3) as u32; + + let mut cum = Vec::with_capacity(face_count as usize); + let mut total = 0.0_f32; + for face in 0..face_count as usize { + let i0 = dense_indices[face * 3] as usize; + let i1 = dense_indices[face * 3 + 1] as usize; + let i2 = dense_indices[face * 3 + 2] as usize; + let p0 = Vec3::from_array(positions[i0]); + let p1 = Vec3::from_array(positions[i1]); + let p2 = Vec3::from_array(positions[i2]); + let area = 0.5 * (p1 - p0).cross(p2 - p0).length(); + total += area; + cum.push(total); + } + if total <= 0.0 { + return Err(ProcessingError::InvalidArgument( + "scatter source mesh has zero surface area".to_string(), + )); + } + let inv = 1.0 / total; + for v in &mut cum { + *v *= inv; + } + + let cdf_bytes: Vec = cum.iter().flat_map(|f| f.to_le_bytes()).collect(); + let indices_bytes: Vec = dense_indices.iter().flat_map(|i| i.to_le_bytes()).collect(); + Ok((cdf_bytes, indices_bytes, face_count)) +} + +pub fn prepare_scatter_volume_source( + In(geom_entity): In, + geometries: Query<&Geometry>, + mut meshes: ResMut>, +) -> Result<(Vec, [f32; 3], [f32; 3], u32)> { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get_mut(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)? + .into_inner(); + + let (positions, dense_indices) = extract_scatter_geometry(mesh)?; + let face_count = (dense_indices.len() / 3) as u32; + + let mut min = Vec3::splat(f32::INFINITY); + let mut max = Vec3::splat(f32::NEG_INFINITY); + for p in &positions { + let v = Vec3::from_array(*p); + min = min.min(v); + max = max.max(v); + } + + let indices_bytes: Vec = dense_indices.iter().flat_map(|i| i.to_le_bytes()).collect(); + Ok((indices_bytes, min.to_array(), max.to_array(), face_count)) +} diff --git a/crates/processing_render/src/particles/sort.rs b/crates/processing_render/src/particles/sort.rs new file mode 100644 index 00000000..052558a6 --- /dev/null +++ b/crates/processing_render/src/particles/sort.rs @@ -0,0 +1,58 @@ +use std::sync::Mutex; + +use bevy::prelude::Entity; + +use processing_core::error::{ProcessingError, Result}; + +use crate::shader_value::ShaderValue; +use crate::{buffer_size, compute_create, compute_dispatch, compute_set, shader_load}; + +const WG: u32 = 64; +const SHADER: &str = "embedded://processing_render/particles/kernels/bitonic.wgsl"; + +static BITONIC: Mutex> = Mutex::new(None); + +fn bitonic_compute() -> Result { + let mut guard = BITONIC.lock().unwrap(); + if let Some(e) = *guard { + return Ok(e); + } + let compute = compute_create(shader_load(SHADER)?)?; + *guard = Some(compute); + Ok(compute) +} + +pub fn bitonic_sort_by_key(keys: Entity, payload: Entity) -> Result<()> { + let n = (buffer_size(keys)? / 4) as u32; + if n <= 1 { + return Ok(()); + } + if !n.is_power_of_two() { + return Err(ProcessingError::InvalidArgument(format!( + "bitonic_sort_by_key: length {n} must be a power of two" + ))); + } + if buffer_size(payload)? / 4 != n as u64 { + return Err(ProcessingError::InvalidArgument( + "bitonic_sort_by_key: keys and payload must have the same length".to_string(), + )); + } + + let compute = bitonic_compute()?; + let workgroups = n.div_ceil(WG); + + let mut k = 2u32; + while k <= n { + let mut j = k / 2; + while j >= 1 { + compute_set(compute, "keys", ShaderValue::Buffer(keys))?; + compute_set(compute, "payload", ShaderValue::Buffer(payload))?; + compute_set(compute, "k", ShaderValue::UInt(k))?; + compute_set(compute, "j", ShaderValue::UInt(j))?; + compute_dispatch(compute, workgroups, 1, 1)?; + j /= 2; + } + k *= 2; + } + Ok(()) +} diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 94c79fc0..d514982b 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -1,7 +1,136 @@ +use bevy::math::Affine3A; use bevy::prelude::*; use bevy::render::render_resource::{BlendComponent, BlendFactor, BlendOperation, BlendState}; +use processing_core::constants as consts; use processing_core::error::{self, ProcessingError}; +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextAlignH { + #[default] + Left = 0, + Center = 1, + Right = 2, +} + +impl From for TextAlignH { + fn from(v: u8) -> Self { + match v { + 0 => Self::Left, + 1 => Self::Center, + 2 => Self::Right, + _ => Self::default(), + } + } +} + +impl TextAlignH { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::LEFT) => Some(Self::Left), + _ if s.eq_ignore_ascii_case(consts::CENTER) => Some(Self::Center), + _ if s.eq_ignore_ascii_case(consts::RIGHT) => Some(Self::Right), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextAlignV { + #[default] + Baseline = 0, + Top = 1, + Center = 2, + Bottom = 3, +} + +impl From for TextAlignV { + fn from(v: u8) -> Self { + match v { + 0 => Self::Baseline, + 1 => Self::Top, + 2 => Self::Center, + 3 => Self::Bottom, + _ => Self::default(), + } + } +} + +impl TextAlignV { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::BASELINE) => Some(Self::Baseline), + _ if s.eq_ignore_ascii_case(consts::TOP) => Some(Self::Top), + _ if s.eq_ignore_ascii_case(consts::CENTER) => Some(Self::Center), + _ if s.eq_ignore_ascii_case(consts::BOTTOM) => Some(Self::Bottom), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextWrapMode { + #[default] + Word = 0, + Char = 1, +} + +impl From for TextWrapMode { + fn from(v: u8) -> Self { + match v { + 0 => Self::Word, + 1 => Self::Char, + _ => Self::default(), + } + } +} + +impl TextWrapMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::WORD) => Some(Self::Word), + _ if s.eq_ignore_ascii_case(consts::CHAR) => Some(Self::Char), + _ => None, + } + } +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(u8)] +pub enum TextStyle { + #[default] + Normal = 0, + Italic = 1, + Bold = 2, + BoldItalic = 3, +} + +impl From for TextStyle { + fn from(v: u8) -> Self { + match v { + 0 => Self::Normal, + 1 => Self::Italic, + 2 => Self::Bold, + 3 => Self::BoldItalic, + _ => Self::default(), + } + } +} + +impl TextStyle { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::NORMAL) => Some(Self::Normal), + _ if s.eq_ignore_ascii_case(consts::ITALIC) => Some(Self::Italic), + _ if s.eq_ignore_ascii_case(consts::BOLD) => Some(Self::Bold), + _ if s.eq_ignore_ascii_case(consts::BOLD_ITALIC) => Some(Self::BoldItalic), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum StrokeCapMode { @@ -22,6 +151,17 @@ impl From for StrokeCapMode { } } +impl StrokeCapMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::ROUND) => Some(Self::Round), + _ if s.eq_ignore_ascii_case(consts::SQUARE) => Some(Self::Square), + _ if s.eq_ignore_ascii_case(consts::PROJECT) => Some(Self::Project), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum StrokeJoinMode { @@ -42,6 +182,17 @@ impl From for StrokeJoinMode { } } +impl StrokeJoinMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::ROUND) => Some(Self::Round), + _ if s.eq_ignore_ascii_case(consts::MITER) => Some(Self::Miter), + _ if s.eq_ignore_ascii_case(consts::BEVEL) => Some(Self::Bevel), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum ArcMode { @@ -62,6 +213,17 @@ impl From for ArcMode { } } +impl ArcMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::OPEN) => Some(Self::Open), + _ if s.eq_ignore_ascii_case(consts::CHORD) => Some(Self::Chord), + _ if s.eq_ignore_ascii_case(consts::PIE) => Some(Self::Pie), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum ShapeMode { @@ -84,6 +246,18 @@ impl From for ShapeMode { } } +impl ShapeMode { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::CORNER) => Some(Self::Corner), + _ if s.eq_ignore_ascii_case(consts::CORNERS) => Some(Self::Corners), + _ if s.eq_ignore_ascii_case(consts::CENTER) => Some(Self::Center), + _ if s.eq_ignore_ascii_case(consts::RADIUS) => Some(Self::Radius), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] #[repr(u8)] pub enum ShapeKind { @@ -114,6 +288,22 @@ impl From for ShapeKind { } } +impl ShapeKind { + pub fn parse(s: &str) -> Option { + match () { + _ if s.eq_ignore_ascii_case(consts::POLYGON) => Some(Self::Polygon), + _ if s.eq_ignore_ascii_case(consts::POINTS) => Some(Self::Points), + _ if s.eq_ignore_ascii_case(consts::LINES) => Some(Self::Lines), + _ if s.eq_ignore_ascii_case(consts::TRIANGLES) => Some(Self::Triangles), + _ if s.eq_ignore_ascii_case(consts::TRIANGLE_FAN) => Some(Self::TriangleFan), + _ if s.eq_ignore_ascii_case(consts::TRIANGLE_STRIP) => Some(Self::TriangleStrip), + _ if s.eq_ignore_ascii_case(consts::QUADS) => Some(Self::Quads), + _ if s.eq_ignore_ascii_case(consts::QUAD_STRIP) => Some(Self::QuadStrip), + _ => None, + } + } +} + #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] #[repr(u8)] pub enum BlendMode { @@ -228,6 +418,22 @@ impl BlendMode { } } + pub fn from_name(name: &str) -> Option { + match name { + "BLEND" => Some(Self::Blend), + "ADD" => Some(Self::Add), + "SUBTRACT" => Some(Self::Subtract), + "DARKEST" => Some(Self::Darkest), + "LIGHTEST" => Some(Self::Lightest), + "DIFFERENCE" => Some(Self::Difference), + "EXCLUSION" => Some(Self::Exclusion), + "MULTIPLY" => Some(Self::Multiply), + "SCREEN" => Some(Self::Screen), + "REPLACE" => Some(Self::Replace), + _ => None, + } + } + pub fn to_blend_state(self) -> Option { use BlendFactor::*; use BlendOperation::*; @@ -297,6 +503,9 @@ pub enum DrawCommand { BackgroundColor(Color), BackgroundImage(Entity), Fill(Color), + /// per-instance albedo for `Particles`: a `compute::Buffer` of `Float4` + /// colors indexed by tag. mutually exclusive with `Fill(Color)`. + FillBuffer(Entity), NoFill, StrokeColor(Color), NoStroke, @@ -307,6 +516,20 @@ pub enum DrawCommand { Metallic(f32), Emissive(Color), Unlit, + Tint(Color), + NoTint, + ImageMode(ShapeMode), + Image { + entity: Entity, + dx: f32, + dy: f32, + d_width: Option, + d_height: Option, + sx: Option, + sy: Option, + s_width: Option, + s_height: Option, + }, RectMode(ShapeMode), EllipseMode(ShapeMode), Rect { @@ -412,11 +635,15 @@ pub enum DrawCommand { PushMatrix, PopMatrix, ResetMatrix, - Translate(Vec2), + ApplyMatrix(Affine3A), + PushStyle, + PopStyle, + Translate(Vec3), Rotate { angle: f32, + axis: Vec3, }, - Scale(Vec2), + Scale(Vec3), ShearX { angle: f32, }, @@ -424,6 +651,11 @@ pub enum DrawCommand { angle: f32, }, Geometry(Entity), + Particles { + particles: Entity, + geometry: Option, + topology: crate::geometry::Topology, + }, BlendMode(Option), Material(Entity), Box { @@ -470,6 +702,38 @@ pub enum DrawCommand { Tetrahedron { radius: f32, }, + TextFont(Option), + TextStyle(TextStyle), + TextWeight(f32), + TextVariation { + tag: [u8; 4], + value: f32, + }, + ClearTextVariations, + TextFeature { + tag: [u8; 4], + value: u16, + }, + NoTextFeature { + tag: [u8; 4], + }, + ClearTextFeatures, + TextSize(f32), + TextAlign { + h: TextAlignH, + v: TextAlignV, + }, + TextLeading(f32), + TextWrap(TextWrapMode), + TextGlyphColors(Vec), + Text { + content: String, + x: f32, + y: f32, + z: f32, + max_w: Option, + max_h: Option, + }, } #[derive(Debug, Default, Component)] diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs new file mode 100644 index 00000000..52a2a9b4 --- /dev/null +++ b/crates/processing_render/src/render/filter.rs @@ -0,0 +1,439 @@ +//! A [`Filter`] wraps a shader as a fullscreen pass. + +use std::collections::{BTreeSet, HashMap}; + +use bevy::{ + core_pipeline::FullscreenShader, + prelude::*, + render::{ + RenderApp, RenderStartup, + render_asset::RenderAssets, + render_resource::{ + BindGroupEntry, BindGroupLayoutDescriptor, CachedPipelineState, CachedRenderPipelineId, + ColorTargetState, ColorWrites, CommandEncoderDescriptor, FragmentState, LoadOp, + Operations, PipelineCache, RenderPassColorAttachment, RenderPassDescriptor, + RenderPipelineDescriptor, Sampler, SamplerDescriptor, StoreOp, TextureFormat, + }, + renderer::{RenderDevice, RenderQueue}, + storage::GpuShaderBuffer, + sync_world::MainEntity, + texture::GpuImage, + view::ViewTarget, + }, + shader::Shader as ShaderAsset, +}; +use bevy_naga_reflect::{dynamic_shader::DynamicShader, reflect::ParameterCategory}; + +use crate::material::custom::{Shader, apply_reflect_field, find_param_containing_field}; +use processing_core::error::{ProcessingError, Result}; + +const F_RESOLUTION: &str = "resolution"; +const F_TEXEL_SIZE: &str = "texel_size"; +const F_PASS_INDEX: &str = "pass_index"; +const F_PASS_COUNT: &str = "pass_count"; + +pub mod builtin { + pub const INVERT: &str = include_str!("filters/invert.wgsl"); + pub const GRAY: &str = include_str!("filters/gray.wgsl"); + pub const THRESHOLD: &str = include_str!("filters/threshold.wgsl"); + pub const POSTERIZE: &str = include_str!("filters/posterize.wgsl"); + pub const OPAQUE: &str = include_str!("filters/opaque.wgsl"); + pub const ERODE: &str = include_str!("filters/erode.wgsl"); + pub const DILATE: &str = include_str!("filters/dilate.wgsl"); + pub const BLUR: &str = include_str!("filters/blur.wgsl"); + pub const COMPOSITE: &str = include_str!("filters/composite.wgsl"); + pub const FEEDBACK: &str = include_str!("filters/feedback.wgsl"); +} + +#[derive(Component)] +pub struct Filter { + pub shader: DynamicShader, + pub entry_point: String, + pub handle: Handle, + pub layouts: Vec<(u32, BindGroupLayoutDescriptor)>, + pub passes: u32, + pub pipelines: HashMap, +} + +#[derive(Resource)] +struct FilterSampler(Sampler); + +#[derive(Resource, Default)] +pub struct FilterRegistry { + /// User filters, keyed by the shader entity they wrap. + pub(crate) by_shader: HashMap, + /// Built-in filters, keyed by their shader source. + pub(crate) builtins: HashMap<&'static str, Entity>, +} + +pub struct FilterPlugin; + +impl Plugin for FilterPlugin { + fn build(&self, app: &mut App) { + app.init_resource::(); + if let Some(render_app) = app.get_sub_app_mut(RenderApp) { + render_app.add_systems(RenderStartup, init_filter_sampler); + } + } +} + +fn init_filter_sampler(mut commands: Commands, render_device: Res) { + let sampler = render_device.create_sampler(&SamplerDescriptor::default()); + commands.insert_resource(FilterSampler(sampler)); +} + +pub fn create(app: &mut App, shader_entity: Entity) -> Result { + if let Some(&filter) = app + .world() + .resource::() + .by_shader + .get(&shader_entity) + { + return Ok(filter); + } + + let (module, handle) = { + let program = app + .world() + .get::(shader_entity) + .ok_or(ProcessingError::ShaderNotFound)?; + (program.module.clone(), program.shader_handle.clone()) + }; + + let entry_point = module + .entry_points + .iter() + .find(|e| e.stage == naga::ShaderStage::Fragment) + .map(|e| e.name.clone()) + .ok_or_else(|| { + ProcessingError::ShaderCompilationError("filter shader has no fragment entry".into()) + })?; + + let mut shader = DynamicShader::new(module) + .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; + shader.init(); + let layouts = reflected_layouts(&shader); + + let filter = app + .world_mut() + .spawn(Filter { + shader, + entry_point, + handle, + layouts, + passes: 1, + pipelines: HashMap::new(), + }) + .id(); + app.world_mut() + .resource_mut::() + .by_shader + .insert(shader_entity, filter); + Ok(filter) +} + +pub fn set_passes( + In((entity, passes)): In<(Entity, u32)>, + mut filters: Query<&mut Filter>, +) -> Result<()> { + let mut filter = filters + .get_mut(entity) + .map_err(|_| ProcessingError::FilterNotFound)?; + filter.passes = passes.max(1); + Ok(()) +} + +pub fn apply(app: &mut App, graphics: Entity, filter: Entity) -> Result<()> { + let (shader, handle, entry, layouts, passes) = { + let f = app + .world() + .get::(filter) + .ok_or(ProcessingError::FilterNotFound)?; + ( + f.shader.clone(), + f.handle.clone(), + f.entry_point.clone(), + f.layouts.clone(), + f.passes, + ) + }; + + // flush() also extracts the shader asset to the render world. + crate::graphics::flush(app, graphics)?; + + // The pipeline must target the view target's actual texture format, which + // bevy may transform from the requested format (HDR, sRGB compositing). + let (format, size) = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(target_info, graphics) + .unwrap()?; + + let pipeline_id = match app + .world() + .get::(filter) + .and_then(|f| f.pipelines.get(&format).copied()) + { + Some(id) => id, + None => { + let id = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(queue_pipeline, (handle, entry, layouts.clone(), format)) + .unwrap(); + if let Some(mut f) = app.world_mut().get_mut::(filter) { + f.pipelines.insert(format, id); + } + id + } + }; + + const MAX_WAIT: u32 = 64; + let mut ready = false; + for _ in 0..MAX_WAIT { + ready = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(pump_pipeline, pipeline_id) + .unwrap()?; + if ready { + break; + } + } + if !ready { + return Err(ProcessingError::PipelineNotReady(MAX_WAIT)); + } + + app.sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with( + run_pass, + (graphics, shader, layouts, pipeline_id, size, passes), + ) + .unwrap() +} + +pub fn destroy( + In(entity): In, + mut commands: Commands, + mut registry: ResMut, +) -> Result<()> { + registry.by_shader.retain(|_, &mut f| f != entity); + registry.builtins.retain(|_, &mut f| f != entity); + commands.entity(entity).despawn(); + Ok(()) +} + +fn reflected_layouts(shader: &DynamicShader) -> Vec<(u32, BindGroupLayoutDescriptor)> { + let reflection = shader.reflection(); + let groups: BTreeSet = reflection.parameters().map(|p| p.group()).collect(); + groups + .into_iter() + .map(|group| { + ( + group, + BindGroupLayoutDescriptor { + label: "processing_filter_layout".into(), + entries: reflection.bind_group_layout(group), + }, + ) + }) + .collect() +} + +fn target_info( + In(entity): In, + targets: Query<(&MainEntity, &ViewTarget)>, +) -> Result<(TextureFormat, UVec2)> { + for (main_entity, vt) in targets.iter() { + if **main_entity == entity { + let size = vt.main_texture().size(); + return Ok(( + vt.main_texture_format(), + UVec2::new(size.width, size.height), + )); + } + } + Err(ProcessingError::GraphicsNotFound) +} + +fn queue_pipeline( + In((handle, entry, layouts, format)): In<( + Handle, + String, + Vec<(u32, BindGroupLayoutDescriptor)>, + TextureFormat, + )>, + pipeline_cache: Res, + fullscreen: Res, +) -> CachedRenderPipelineId { + let max_group = layouts.iter().map(|(g, _)| *g).max().map_or(0, |g| g + 1); + let mut layout = vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + for (group, desc) in &layouts { + layout[*group as usize] = desc.clone(); + } + + let descriptor = RenderPipelineDescriptor { + label: Some("processing_filter_pipeline".into()), + layout, + vertex: fullscreen.to_vertex_state(), + fragment: Some(FragmentState { + shader: handle, + entry_point: Some(entry.into()), + targets: vec![Some(ColorTargetState { + format, + blend: None, + write_mask: ColorWrites::ALL, + })], + ..default() + }), + ..default() + }; + pipeline_cache.queue_render_pipeline(descriptor) +} + +fn pump_pipeline( + In(id): In, + mut pipeline_cache: ResMut, +) -> Result { + pipeline_cache.process_queue(); + match pipeline_cache.get_render_pipeline_state(id) { + CachedPipelineState::Ok(_) => Ok(true), + CachedPipelineState::Err(e) => Err(ProcessingError::PipelineCompileError(format!("{e}"))), + _ => Ok(false), + } +} + +#[allow(clippy::too_many_arguments)] +fn run_pass( + In((entity, mut shader, layouts, pipeline_id, size, passes)): In<( + Entity, + DynamicShader, + Vec<(u32, BindGroupLayoutDescriptor)>, + CachedRenderPipelineId, + UVec2, + u32, + )>, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + gpu_buffers: Res>, + filter_sampler: Res, + targets: Query<(&MainEntity, &ViewTarget)>, +) -> Result<()> { + let pipeline = pipeline_cache + .get_render_pipeline(pipeline_id) + .ok_or(ProcessingError::PipelineNotReady(0))?; + + let mut view_target = None; + for (main_entity, vt) in targets.iter() { + if **main_entity == entity { + view_target = Some(vt); + break; + } + } + let view_target = view_target.ok_or(ProcessingError::GraphicsNotFound)?; + + // Fill the engine-owned `filter` uniform (only the fields the shader uses). + let resolution = Vec2::new(size.x as f32, size.y as f32); + let texel = Vec2::new(1.0 / resolution.x, 1.0 / resolution.y); + fill_system(&mut shader, F_RESOLUTION, &resolution); + fill_system(&mut shader, F_TEXEL_SIZE, &texel); + fill_system(&mut shader, F_PASS_COUNT, &passes); + let has_pass_index = find_param_containing_field(&shader, F_PASS_INDEX).is_some(); + + // wesl mangles the imported `processing::filter` bindings, so the screen + // texture and sampler are bound by their reflected (mangled) names, found by + // category in group 0. + let (input_texture, input_sampler) = { + let reflection = shader.reflection(); + let mut texture = None; + let mut sampler = None; + for param in reflection.parameters().filter(|p| p.group() == 0) { + match param.category() { + ParameterCategory::Texture => texture = param.name().map(String::from), + ParameterCategory::Sampler => sampler = param.name().map(String::from), + _ => {} + } + } + (texture, sampler) + }; + + for pass in 0..passes { + if has_pass_index { + let _ = apply_reflect_field(&mut shader, F_PASS_INDEX, &pass); + } + + let post_process = view_target.post_process_write(); + if let Some(name) = &input_texture { + shader.insert_texture_view(name, post_process.source.clone()); + } + if let Some(name) = &input_sampler { + shader.insert_sampler(name, filter_sampler.0.clone()); + } + + let reflection = shader.reflection(); + let mut bind_groups = Vec::new(); + for (group, desc) in &layouts { + let layout = pipeline_cache.get_bind_group_layout(desc); + let bindings = reflection.create_bindings( + *group, + &shader, + &render_device, + &gpu_images, + &gpu_buffers, + ); + let entries: Vec = bindings + .iter() + .map(|(binding, resource)| BindGroupEntry { + binding: *binding, + resource: resource.get_binding(), + }) + .collect(); + bind_groups.push(( + *group, + render_device.create_bind_group( + Some("processing_filter_bind_group"), + &layout, + &entries, + ), + )); + } + + let mut encoder = + render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + { + let mut render = encoder.begin_render_pass(&RenderPassDescriptor { + label: Some("processing_filter_pass"), + color_attachments: &[Some(RenderPassColorAttachment { + view: post_process.destination, + depth_slice: None, + resolve_target: None, + ops: Operations { + load: LoadOp::Load, + store: StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + multiview_mask: None, + timestamp_writes: None, + occlusion_query_set: None, + }); + render.set_pipeline(pipeline); + for (group, bind_group) in &bind_groups { + render.set_bind_group(*group, bind_group, &[]); + } + render.draw(0..3, 0..1); + } + render_queue.submit(std::iter::once(encoder.finish())); + } + + Ok(()) +} + +fn fill_system(shader: &mut DynamicShader, name: &str, value: &dyn PartialReflect) { + if find_param_containing_field(shader, name).is_some() { + let _ = apply_reflect_field(shader, name, value); + } +} diff --git a/crates/processing_render/src/render/filters/blur.wgsl b/crates/processing_render/src/render/filters/blur.wgsl new file mode 100644 index 00000000..b0b17a9b --- /dev/null +++ b/crates/processing_render/src/render/filters/blur.wgsl @@ -0,0 +1,29 @@ +import processing::filter::{sample, texel_size, pass_index, FullscreenVertexOutput}; + +struct Params { + radius: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let dir = select(vec2(0.0, t.y), vec2(t.x, 0.0), pass_index() == 0u); + + let radius = max(params.radius, 1.0); + let sigma = radius * 0.5 + 0.5; + let r = i32(radius); + + var sum = vec4(0.0); + var weight_sum = 0.0; + for (var i = -r; i <= r; i++) { + let x = f32(i); + let w = exp(-(x * x) / (2.0 * sigma * sigma)); + sum += sample(in.uv + dir * x) * w; + weight_sum += w; + } + return sum / weight_sum; +} diff --git a/crates/processing_render/src/render/filters/composite.wgsl b/crates/processing_render/src/render/filters/composite.wgsl new file mode 100644 index 00000000..2d4196ac --- /dev/null +++ b/crates/processing_render/src/render/filters/composite.wgsl @@ -0,0 +1,87 @@ +// Composite (blend) filter: combines the destination surface (operand A, sampled +// from the screen texture) with a source input texture (operand B, `src`) using a +// blend mode. This is the shader-based composite the fixed-function blend path +// cannot express exactly (MULTIPLY, DIFFERENCE, EXCLUSION, MASK). +// +// `mode` matches the `BlendMode` enum discriminants: +// 0 BLEND, 1 ADD, 2 SUBTRACT, 3 DARKEST, 4 LIGHTEST, 5 DIFFERENCE, +// 6 EXCLUSION, 7 MULTIPLY, 8 SCREEN, 9 REPLACE (copy), 10 MASK. +// +// `src_rect`/`dst_rect` are normalized (uv) rectangles (x0, y0, x1, y1). Only +// pixels inside `dst_rect` are affected; the source is sampled across `src_rect`, +// so a smaller/larger dst_rect scales the source into place. +import processing::filter::{sample, sample_texture, FullscreenVertexOutput}; + +struct CompositeParams { + src_rect: vec4, + dst_rect: vec4, + mode: u32, + opacity: f32, + _p0: f32, + _p1: f32, +} + +@group(1) @binding(0) var src: texture_2d; +@group(1) @binding(1) var composite_params: CompositeParams; + +fn luminance(c: vec3) -> f32 { + return dot(c, vec3(0.2126, 0.7152, 0.0722)); +} + +// Per-channel blend of destination `d` and source `s` for the non-alpha-defined +// modes (BLEND/REPLACE/MASK are handled in `composite`). +fn blend_rgb(d: vec3, s: vec3, mode: u32) -> vec3 { + switch mode { + case 1u: { return d + s; } // ADD + case 2u: { return d - s; } // SUBTRACT + case 3u: { return min(d, s); } // DARKEST + case 4u: { return max(d, s); } // LIGHTEST + case 5u: { return abs(d - s); } // DIFFERENCE + case 6u: { return d + s - 2.0 * d * s; } // EXCLUSION + case 7u: { return d * s; } // MULTIPLY + case 8u: { return 1.0 - (1.0 - d) * (1.0 - s); } // SCREEN + default: { return s; } + } +} + +fn composite(d: vec4, s: vec4, mode: u32, opacity: f32) -> vec4 { + // BLEND: straight-alpha source-over. + if (mode == 0u) { + let a = s.a * opacity; + let out_a = a + d.a * (1.0 - a); + var rgb = vec3(0.0); + if (out_a > 0.0) { + rgb = (s.rgb * a + d.rgb * d.a * (1.0 - a)) / out_a; + } + return vec4(rgb, out_a); + } + // REPLACE / copy. + if (mode == 9u) { + return mix(d, s, opacity); + } + // MASK: keep destination color, take alpha from the source's luminance. + if (mode == 10u) { + return vec4(d.rgb, d.a * mix(1.0, luminance(s.rgb), opacity)); + } + // Remaining modes define a blended rgb, applied over the destination scaled + // by the (opacity-weighted) source alpha. + let a = s.a * opacity; + let blended = clamp(blend_rgb(d.rgb, s.rgb, mode), vec3(0.0), vec3(1.0)); + return vec4(mix(d.rgb, blended, a), d.a); +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let d = sample(in.uv); + + let dmin = composite_params.dst_rect.xy; + let dmax = composite_params.dst_rect.zw; + if (in.uv.x < dmin.x || in.uv.y < dmin.y || in.uv.x > dmax.x || in.uv.y > dmax.y) { + return d; + } + + let local = (in.uv - dmin) / max(dmax - dmin, vec2(1e-6)); + let suv = mix(composite_params.src_rect.xy, composite_params.src_rect.zw, local); + let s = sample_texture(src, suv); + return composite(d, s, composite_params.mode, composite_params.opacity); +} diff --git a/crates/processing_render/src/render/filters/dilate.wgsl b/crates/processing_render/src/render/filters/dilate.wgsl new file mode 100644 index 00000000..739b9528 --- /dev/null +++ b/crates/processing_render/src/render/filters/dilate.wgsl @@ -0,0 +1,27 @@ +import processing::filter::{sample, texel_size, FullscreenVertexOutput}; + +fn luma(c: vec3) -> f32 { + return 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let offsets = array, 4>( + vec2(-t.x, 0.0), + vec2(t.x, 0.0), + vec2(0.0, -t.y), + vec2(0.0, t.y), + ); + var best = sample(in.uv); + var best_luma = luma(best.rgb); + for (var i = 0; i < 4; i++) { + let s = sample(in.uv + offsets[i]); + let sl = luma(s.rgb); + if sl > best_luma { + best = s; + best_luma = sl; + } + } + return best; +} diff --git a/crates/processing_render/src/render/filters/erode.wgsl b/crates/processing_render/src/render/filters/erode.wgsl new file mode 100644 index 00000000..abbabd28 --- /dev/null +++ b/crates/processing_render/src/render/filters/erode.wgsl @@ -0,0 +1,27 @@ +import processing::filter::{sample, texel_size, FullscreenVertexOutput}; + +fn luma(c: vec3) -> f32 { + return 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; +} + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let t = texel_size(); + let offsets = array, 4>( + vec2(-t.x, 0.0), + vec2(t.x, 0.0), + vec2(0.0, -t.y), + vec2(0.0, t.y), + ); + var best = sample(in.uv); + var best_luma = luma(best.rgb); + for (var i = 0; i < 4; i++) { + let s = sample(in.uv + offsets[i]); + let sl = luma(s.rgb); + if sl < best_luma { + best = s; + best_luma = sl; + } + } + return best; +} diff --git a/crates/processing_render/src/render/filters/feedback.wgsl b/crates/processing_render/src/render/filters/feedback.wgsl new file mode 100644 index 00000000..9e43cbb1 --- /dev/null +++ b/crates/processing_render/src/render/filters/feedback.wgsl @@ -0,0 +1,34 @@ +// Feedback filter: samples the surface's *previous* frame (operand A) with a +// zoom/rotate/offset transform and a per-frame decay, writing it back. On a +// graphics context that isn't cleared each frame, applying this at the start of +// draw() and then drawing new content on top produces feedback trails +// (TouchDesigner Feedback-TOP style). This is just a filter whose input is the +// target's own history. +import processing::filter::{sample, FullscreenVertexOutput}; + +struct FeedbackParams { + offset: vec2, + zoom: f32, + angle: f32, + decay: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} + +@group(1) @binding(0) var feedback: FeedbackParams; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let center = vec2(0.5, 0.5); + var uv = in.uv - center; + + // Rotate then inverse-zoom around the center (sampling the previous frame). + let s = sin(feedback.angle); + let c = cos(feedback.angle); + uv = mat2x2(c, -s, s, c) * uv; + uv = uv / max(feedback.zoom, 0.0001); + + uv = uv + center - feedback.offset; + return sample(uv) * feedback.decay; +} diff --git a/crates/processing_render/src/render/filters/gray.wgsl b/crates/processing_render/src/render/filters/gray.wgsl new file mode 100644 index 00000000..ad3c1f27 --- /dev/null +++ b/crates/processing_render/src/render/filters/gray.wgsl @@ -0,0 +1,8 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = sample(in.uv); + let luma = 0.30078125 * c.r + 0.58984375 * c.g + 0.109375 * c.b; + return vec4(luma, luma, luma, c.a); +} diff --git a/crates/processing_render/src/render/filters/invert.wgsl b/crates/processing_render/src/render/filters/invert.wgsl new file mode 100644 index 00000000..2fd08dcd --- /dev/null +++ b/crates/processing_render/src/render/filters/invert.wgsl @@ -0,0 +1,7 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = sample(in.uv); + return vec4(1.0 - c.r, 1.0 - c.g, 1.0 - c.b, c.a); +} diff --git a/crates/processing_render/src/render/filters/opaque.wgsl b/crates/processing_render/src/render/filters/opaque.wgsl new file mode 100644 index 00000000..34ffb3f2 --- /dev/null +++ b/crates/processing_render/src/render/filters/opaque.wgsl @@ -0,0 +1,7 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = sample(in.uv); + return vec4(c.rgb, 1.0); +} diff --git a/crates/processing_render/src/render/filters/posterize.wgsl b/crates/processing_render/src/render/filters/posterize.wgsl new file mode 100644 index 00000000..9270170c --- /dev/null +++ b/crates/processing_render/src/render/filters/posterize.wgsl @@ -0,0 +1,17 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +struct Params { + levels: u32, + _p0: u32, + _p1: u32, + _p2: u32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = sample(in.uv); + let n = f32(max(params.levels, 2u)); + let q = floor(c.rgb * n) / (n - 1.0); + return vec4(clamp(q, vec3(0.0), vec3(1.0)), c.a); +} diff --git a/crates/processing_render/src/render/filters/threshold.wgsl b/crates/processing_render/src/render/filters/threshold.wgsl new file mode 100644 index 00000000..ee779d7d --- /dev/null +++ b/crates/processing_render/src/render/filters/threshold.wgsl @@ -0,0 +1,17 @@ +import processing::filter::{sample, FullscreenVertexOutput}; + +struct Params { + cutoff: f32, + _p0: f32, + _p1: f32, + _p2: f32, +} +@group(1) @binding(0) var params: Params; + +@fragment +fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4 { + let c = sample(in.uv); + let m = max(c.r, max(c.g, c.b)); + let v = select(0.0, 1.0, m >= params.cutoff); + return vec4(v, v, v, c.a); +} diff --git a/crates/processing_render/src/render/material.rs b/crates/processing_render/src/render/material.rs index 3775f92c..d6ab6e4f 100644 --- a/crates/processing_render/src/render/material.rs +++ b/crates/processing_render/src/render/material.rs @@ -1,3 +1,4 @@ +use bevy::math::Affine2; use bevy::pbr::ExtendedMaterial; use bevy::prelude::*; use bevy::render::render_resource::BlendState; @@ -11,11 +12,12 @@ pub struct UntypedMaterial(pub UntypedHandle); pub type ProcessingExtendedMaterial = ExtendedMaterial; -#[derive(Clone, PartialEq, Eq, Hash, Debug)] +#[derive(Clone, PartialEq, Debug)] pub enum MaterialKey { Color { transparent: bool, background_image: Option>, + uv_transform: Affine2, blend_state: Option, }, Pbr { @@ -23,6 +25,8 @@ pub enum MaterialKey { roughness: u8, metallic: u8, emissive: [u8; 4], + base_color_texture: Option>, + uv_transform: Affine2, blend_state: Option, }, Custom { @@ -31,7 +35,68 @@ pub enum MaterialKey { }, } +pub struct PbrFields { + pub albedo: [u8; 4], + pub roughness: u8, + pub metallic: u8, + pub emissive: [u8; 4], + pub base_color_texture: Option>, + pub uv_transform: Affine2, + pub blend_state: Option, +} + +impl Default for PbrFields { + fn default() -> Self { + Self { + albedo: [255, 255, 255, 255], + roughness: 128, + metallic: 0, + emissive: [0, 0, 0, 0], + base_color_texture: None, + uv_transform: Affine2::IDENTITY, + blend_state: None, + } + } +} + +impl From for MaterialKey { + fn from(f: PbrFields) -> Self { + MaterialKey::Pbr { + albedo: f.albedo, + roughness: f.roughness, + metallic: f.metallic, + emissive: f.emissive, + base_color_texture: f.base_color_texture, + uv_transform: f.uv_transform, + blend_state: f.blend_state, + } + } +} + impl MaterialKey { + pub fn as_pbr(&self) -> PbrFields { + match self { + MaterialKey::Pbr { + albedo, + roughness, + metallic, + emissive, + base_color_texture, + uv_transform, + blend_state, + } => PbrFields { + albedo: *albedo, + roughness: *roughness, + metallic: *metallic, + emissive: *emissive, + base_color_texture: base_color_texture.clone(), + uv_transform: *uv_transform, + blend_state: *blend_state, + }, + _ => PbrFields::default(), + } + } + pub fn blend_state(&self) -> Option { match self { MaterialKey::Color { blend_state, .. } => *blend_state, @@ -45,12 +110,14 @@ impl MaterialKey { MaterialKey::Color { transparent, background_image, + uv_transform, blend_state, } => StandardMaterial { base_color: Color::WHITE, unlit: true, cull_mode: None, base_color_texture: background_image.clone(), + uv_transform: *uv_transform, alpha_mode: if blend_state.is_some() || *transparent { AlphaMode::Blend } else { @@ -63,6 +130,8 @@ impl MaterialKey { roughness, metallic, emissive, + base_color_texture, + uv_transform, .. } => { let base_color = Color::srgba( @@ -83,6 +152,8 @@ impl MaterialKey { emissive[2] as f32 / 255.0, emissive[3] as f32 / 255.0, ), + base_color_texture: base_color_texture.clone(), + uv_transform: *uv_transform, ..default() } } @@ -98,7 +169,10 @@ impl MaterialKey { let base = self.to_standard_material(); let extended = ProcessingExtendedMaterial { base, - extension: ProcessingMaterial { blend_state }, + extension: ProcessingMaterial { + blend_state, + depth_write: None, + }, }; materials.add(extended).untyped() } diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 4f739229..685f6f80 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -1,24 +1,31 @@ pub mod command; +pub mod filter; pub mod material; pub mod mesh_builder; pub mod primitive; +pub mod style; pub mod transform; use bevy::{ - camera::visibility::RenderLayers, + camera::{ + primitives::Aabb, + visibility::{NoFrustumCulling, RenderLayers}, + }, ecs::system::SystemParam, - math::{Affine3A, Mat4, Vec4}, + math::{Affine2, Affine3A, Mat4, Vec3A, Vec4}, + pbr::gpu_instance_batch::GpuBatchedMesh3d, prelude::*, render::render_resource::BlendState, }; use command::{CommandBuffer, DrawCommand, ShapeMode}; use material::{MaterialKey, ProcessingExtendedMaterial}; use primitive::{ - ShapeBuilder, StrokeConfig, TessellationMode, VertexType, arc_fill, arc_stroke, bezier, - box_mesh, build_direct_fill, build_direct_stroke, build_polygon_fill, build_polygon_stroke, - capsule_mesh, cone_mesh, conical_frustum_mesh, curve, cylinder_mesh, ellipse, empty_mesh, line, - plane_mesh, quad, sphere_mesh, tetrahedron_mesh, torus_mesh, triangle, + ShapeBuilder, TessellationMode, VertexType, arc_fill, arc_stroke, bezier, box_mesh, + build_direct_fill, build_direct_stroke, build_polygon_fill, build_polygon_stroke, capsule_mesh, + cone_mesh, conical_frustum_mesh, curve, cylinder_mesh, ellipse, empty_mesh, line, plane_mesh, + quad, sphere_mesh, tetrahedron_mesh, torus_mesh, triangle, }; +use style::{Fill, StyleStack}; use transform::TransformStack; use crate::{ @@ -28,7 +35,9 @@ use crate::{ image::Image, material::ProcessingMaterial, material::custom::CustomMaterial, + particles::{Particles, ParticlesDraw}, render::{material::UntypedMaterial, primitive::rect}, + text::font::TextContext, }; pub(crate) const BATCH_INDEX_STEP: f32 = 0.001; @@ -47,6 +56,8 @@ pub struct RenderResources<'w, 's> { meshes: ResMut<'w, Assets>, materials: ResMut<'w, Assets>, custom_materials: ResMut<'w, Assets>, + particles_materials: ResMut<'w, Assets>, + particle_buffers: Query<'w, 's, &'static crate::compute::Buffer>, } struct BatchState { @@ -73,66 +84,45 @@ impl BatchState { #[derive(Debug, Component)] pub struct RenderState { - pub fill_color: Option, - pub stroke_color: Option, - pub stroke_weight: f32, - pub stroke_config: StrokeConfig, - pub material_key: MaterialKey, - pub blend_state: Option, + pub style: StyleStack, pub transform: TransformStack, - pub rect_mode: ShapeMode, - pub ellipse_mode: ShapeMode, pub shape_builder: Option, } impl RenderState { pub fn new() -> Self { Self { - fill_color: Some(Color::WHITE), - stroke_color: Some(Color::BLACK), - stroke_weight: 1.0, - stroke_config: StrokeConfig::default(), - material_key: MaterialKey::Color { - transparent: false, - background_image: None, - blend_state: None, - }, - blend_state: None, + style: StyleStack::new(), transform: TransformStack::new(), - rect_mode: ShapeMode::Corner, - ellipse_mode: ShapeMode::Center, shape_builder: None, } } pub fn reset(&mut self) { - self.fill_color = Some(Color::WHITE); - self.stroke_color = Some(Color::BLACK); - self.stroke_weight = 1.0; - self.stroke_config = StrokeConfig::default(); - self.material_key = MaterialKey::Color { - transparent: false, - background_image: None, - blend_state: None, - }; - self.blend_state = None; + self.style = StyleStack::new(); self.transform = TransformStack::new(); - self.rect_mode = ShapeMode::Corner; - self.ellipse_mode = ShapeMode::Center; self.shape_builder = None; } pub fn begin_frame(&mut self) { self.transform = TransformStack::new(); + self.style.clear_saved(); self.shape_builder = None; } pub fn fill_is_transparent(&self) -> bool { - self.fill_color.map(|c| c.alpha() < 1.0).unwrap_or(false) + self.style + .fill + .color() + .map(|c| c.alpha() < 1.0) + .unwrap_or(false) } pub fn stroke_is_transparent(&self) -> bool { - self.stroke_color.map(|c| c.alpha() < 1.0).unwrap_or(false) + self.style + .stroke_color + .map(|c| c.alpha() < 1.0) + .unwrap_or(false) } } @@ -158,10 +148,30 @@ pub fn flush_draw_commands( p_images: Query<&Image>, p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, + mut p_particles: Query<&mut Particles>, + p_particle_buffers: Query<&crate::compute::Buffer>, + builtin_attributes: Res, + p_fonts: Query<&crate::text::font::Font>, + text_cx: Res, + p_raster_draws: Query< + (Entity, &RenderLayers), + Or<( + With, + With, + )>, + >, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() { + for (raster_entity, raster_layers) in p_raster_draws.iter() { + if raster_layers.intersects(render_layers) { + res.commands + .entity(raster_entity) + .insert(Visibility::Hidden); + } + } + let clip_from_view = projection.get_clip_from_view(); let view_from_world = camera_transform.to_matrix().inverse(); let world_from_clip = (clip_from_view * view_from_world).inverse(); @@ -171,112 +181,64 @@ pub fn flush_draw_commands( for cmd in draw_commands { match cmd { DrawCommand::Fill(color) => { - state.fill_color = Some(color); + state.style.fill = Fill::Color(color); + } + DrawCommand::FillBuffer(buf_entity) => { + state.style.fill = Fill::Buffer(buf_entity); } DrawCommand::NoFill => { - state.fill_color = None; + state.style.fill = Fill::None; } DrawCommand::StrokeColor(color) => { - state.stroke_color = Some(color); + state.style.stroke_color = Some(color); } DrawCommand::NoStroke => { - state.stroke_color = None; + state.style.stroke_color = None; } DrawCommand::StrokeWeight(weight) => { - state.stroke_weight = weight; + state.style.stroke_weight = weight; } DrawCommand::StrokeCap(cap) => { - state.stroke_config.line_cap = cap; + state.style.stroke_config.line_cap = cap; } DrawCommand::StrokeJoin(join) => { - state.stroke_config.line_join = join; + state.style.stroke_config.line_join = join; } DrawCommand::Roughness(r) => { - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - metallic, - emissive, - .. - } => MaterialKey::Pbr { - albedo, - roughness: (r * 255.0) as u8, - metallic, - emissive, - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: (r * 255.0) as u8, - metallic: 0, - emissive: [0, 0, 0, 0], - blend_state: None, - }, - }; + let mut pbr = state.style.material_key.as_pbr(); + pbr.roughness = (r * 255.0) as u8; + pbr.blend_state = None; + state.style.material_key = pbr.into(); } DrawCommand::Metallic(m) => { - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - roughness, - emissive, - .. - } => MaterialKey::Pbr { - albedo, - roughness, - metallic: (m * 255.0) as u8, - emissive, - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: 128, - metallic: (m * 255.0) as u8, - emissive: [0, 0, 0, 0], - blend_state: None, - }, - }; + let mut pbr = state.style.material_key.as_pbr(); + pbr.metallic = (m * 255.0) as u8; + pbr.blend_state = None; + state.style.material_key = pbr.into(); } DrawCommand::Emissive(color) => { - let [r, g, b, a] = color.to_srgba().to_u8_array(); - state.material_key = match state.material_key { - MaterialKey::Pbr { - albedo, - roughness, - metallic, - .. - } => MaterialKey::Pbr { - albedo, - roughness, - metallic, - emissive: [r, g, b, a], - blend_state: None, - }, - _ => MaterialKey::Pbr { - albedo: [255, 255, 255, 255], - roughness: 128, - metallic: 0, - emissive: [r, g, b, a], - blend_state: None, - }, - }; + let mut pbr = state.style.material_key.as_pbr(); + pbr.emissive = color.to_srgba().to_u8_array(); + pbr.blend_state = None; + state.style.material_key = pbr.into(); } DrawCommand::Unlit => { - state.material_key = MaterialKey::Color { + state.style.material_key = MaterialKey::Color { transparent: state.fill_is_transparent(), background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: None, }; } DrawCommand::RectMode(mode) => { - state.rect_mode = mode; + state.style.rect_mode = mode; } DrawCommand::EllipseMode(mode) => { - state.ellipse_mode = mode; + state.style.ellipse_mode = mode; } DrawCommand::Rect { x, y, w, h, radii } => { - let (x, y, w, h) = apply_shape_mode(state.rect_mode, x, y, w, h); - let stroke_config = state.stroke_config; + let (x, y, w, h) = apply_shape_mode(state.style.rect_mode, x, y, w, h); + let stroke_config = state.style.stroke_config; add_fill( &mut res, &mut batch, @@ -320,10 +282,10 @@ pub fn flush_draw_commands( DrawCommand::Ellipse { cx, cy, w, h } => { // apply_shape_mode converts to top-left corner form, then we // compute center from that - let (x, y, w, h) = apply_shape_mode(state.ellipse_mode, cx, cy, w, h); + let (x, y, w, h) = apply_shape_mode(state.style.ellipse_mode, cx, cy, w, h); let cx = x + w / 2.0; let cy = y + h / 2.0; - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_fill( &mut res, &mut batch, @@ -363,7 +325,7 @@ pub fn flush_draw_commands( ); } DrawCommand::Line { x1, y1, x2, y2 } => { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_stroke( &mut res, &mut batch, @@ -382,7 +344,7 @@ pub fn flush_draw_commands( x3, y3, } => { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_fill( &mut res, &mut batch, @@ -435,7 +397,7 @@ pub fn flush_draw_commands( x4, y4, } => { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_fill( &mut res, &mut batch, @@ -483,11 +445,13 @@ pub fn flush_draw_commands( ); } DrawCommand::Point { x, y } => { - // Point is rendered as a filled circle using stroke color and weight - if let Some(color) = state.stroke_color { - let d = state.stroke_weight; - let material_key = - material_key_with_color(&state.material_key, color, state.blend_state); + if let Some(color) = state.style.stroke_color { + let d = state.style.stroke_weight; + let material_key = material_key_with_color( + &state.style.material_key, + color, + state.style.blend_state, + ); if needs_batch(&batch, &state, &material_key) { start_batch( @@ -500,7 +464,7 @@ pub fn flush_draw_commands( } if let Some(ref mut mesh) = batch.current_mesh { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; ellipse( mesh, x, @@ -523,10 +487,10 @@ pub fn flush_draw_commands( stop, mode, } => { - let (x, y, w, h) = apply_shape_mode(state.ellipse_mode, cx, cy, w, h); + let (x, y, w, h) = apply_shape_mode(state.style.ellipse_mode, cx, cy, w, h); let cx = x + w / 2.0; let cy = y + h / 2.0; - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_fill( &mut res, &mut batch, @@ -569,7 +533,7 @@ pub fn flush_draw_commands( x4, y4, } => { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_stroke( &mut res, &mut batch, @@ -603,7 +567,7 @@ pub fn flush_draw_commands( x4, y4, } => { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; add_stroke( &mut res, &mut batch, @@ -676,7 +640,7 @@ pub fn flush_draw_commands( } DrawCommand::EndShape { close } => { if let Some(sb) = state.shape_builder.take() { - let stroke_config = state.stroke_config; + let stroke_config = state.style.stroke_config; use crate::render::command::ShapeKind; match sb.kind { @@ -708,13 +672,12 @@ pub fn flush_draw_commands( ); } ShapeKind::Points => { - // Each vertex rendered as a filled circle - if let Some(color) = state.stroke_color { - let d = state.stroke_weight; + if let Some(color) = state.style.stroke_color { + let d = state.style.stroke_weight; let material_key = material_key_with_color( - &state.material_key, + &state.style.material_key, color, - state.blend_state, + state.style.blend_state, ); if needs_batch(&batch, &state, &material_key) { start_batch( @@ -788,6 +751,82 @@ pub fn flush_draw_commands( } } } + DrawCommand::Tint(color) => { + state.style.tint_color = Some(color); + } + DrawCommand::NoTint => { + state.style.tint_color = None; + } + DrawCommand::ImageMode(mode) => { + state.style.image_mode = mode; + } + DrawCommand::Image { + entity, + dx, + dy, + d_width, + d_height, + sx, + sy, + s_width, + s_height, + } => { + let Some(p_image) = p_images.get(entity).ok() else { + warn!("Could not find PImage for entity {:?}", entity); + continue; + }; + + let img_w = p_image.size.width as f32; + let img_h = p_image.size.height as f32; + let dw = d_width.unwrap_or(img_w); + let dh = d_height.unwrap_or(img_h); + let (x, y, w, h) = apply_shape_mode(state.style.image_mode, dx, dy, dw, dh); + + let uv_xform = match (sx, sy, s_width, s_height) { + (Some(sx), Some(sy), Some(sw), Some(sh)) => { + Affine2::from_scale_angle_translation( + Vec2::new(sw / img_w, sh / img_h), + 0.0, + Vec2::new(sx / img_w, sy / img_h), + ) + } + _ => Affine2::IDENTITY, + }; + + let tint = state.style.tint_color.unwrap_or(Color::WHITE); + let material_key = MaterialKey::Color { + transparent: tint.alpha() < 1.0, + background_image: Some(p_image.handle.clone()), + uv_transform: uv_xform, + blend_state: state.style.blend_state, + }; + let stroke_config = state.style.stroke_config; + + flush_batch(&mut res, &mut batch, &p_material_handles); + start_batch( + &mut res, + &mut batch, + &state, + material_key, + &p_material_handles, + ); + + if let Some(ref mut mesh) = batch.current_mesh { + rect( + mesh, + x, + y, + w, + h, + [0.0; 4], + tint, + TessellationMode::Fill, + &stroke_config, + ); + } + + flush_batch(&mut res, &mut batch, &p_material_handles); + } DrawCommand::BackgroundColor(color) => { flush_batch(&mut res, &mut batch, &p_material_handles); @@ -797,6 +836,7 @@ pub fn flush_draw_commands( let material_key = MaterialKey::Color { transparent: color.alpha() < 1.0, background_image: None, + uv_transform: Affine2::IDENTITY, blend_state: Some(BlendState::REPLACE), }; let material_handle = material_key.to_material(&mut res.materials); @@ -825,6 +865,7 @@ pub fn flush_draw_commands( let material_key = MaterialKey::Color { transparent: false, background_image: Some(p_image.handle.clone()), + uv_transform: Affine2::IDENTITY, blend_state: Some(BlendState::REPLACE), }; let material_handle = material_key.to_material(&mut res.materials); @@ -842,11 +883,16 @@ pub fn flush_draw_commands( DrawCommand::PushMatrix => state.transform.push(), DrawCommand::PopMatrix => state.transform.pop(), DrawCommand::ResetMatrix => state.transform.reset(), - DrawCommand::Translate(v) => state.transform.translate(v.x, v.y), - DrawCommand::Rotate { angle } => state.transform.rotate(angle), - DrawCommand::Scale(v) => state.transform.scale(v.x, v.y), - DrawCommand::ShearX { angle } => state.transform.shear_x(angle), - DrawCommand::ShearY { angle } => state.transform.shear_y(angle), + DrawCommand::ApplyMatrix(m) => state.transform.apply(m), + DrawCommand::PushStyle => state.style.push(), + DrawCommand::PopStyle => state.style.pop(), + DrawCommand::Translate(v) => state.transform.apply(Affine3A::from_translation(v)), + DrawCommand::Rotate { angle, axis } => state + .transform + .apply(Affine3A::from_axis_angle(axis.normalize(), angle)), + DrawCommand::Scale(v) => state.transform.apply(Affine3A::from_scale(v)), + DrawCommand::ShearX { angle } => state.transform.apply(transform::shear_x(angle)), + DrawCommand::ShearY { angle } => state.transform.apply(transform::shear_y(angle)), DrawCommand::Geometry(entity) => { let Some((geometry, node_transform)) = p_geometries.get(entity).ok() else { warn!("Could not find Geometry for entity {:?}", entity); @@ -896,11 +942,185 @@ pub fn flush_draw_commands( batch.draw_index += 1; } + DrawCommand::Particles { + particles, + geometry: None, + topology, + } => { + let Ok(mut particles_data) = p_particles.get_mut(particles) else { + warn!("Could not find Particles for entity {:?}", particles); + continue; + }; + + let position_attr = builtin_attributes.position; + let Some(&buffer_entity) = particles_data.buffers.get(&position_attr) else { + warn!( + "particles(p) with no geometry needs a materialized `position` buffer" + ); + continue; + }; + let Ok(buffer) = p_particle_buffers.get(buffer_entity) else { + warn!("position buffer {:?} has no compute::Buffer", buffer_entity); + continue; + }; + let position = buffer.handle.clone(); + let count = particles_data.capacity; + + let (index, indirect) = match particles_data.connectivity { + Some(connectivity) => { + let (Ok(index_buf), Ok(indirect_buf)) = ( + p_particle_buffers.get(connectivity.index_buffer), + p_particle_buffers.get(connectivity.indirect_buffer), + ) else { + warn!("connectivity buffers for {:?} not found", particles); + continue; + }; + ( + Some(index_buf.handle.clone()), + Some(indirect_buf.handle.clone()), + ) + } + None => (None, None), + }; + + let color = particles_data + .buffers + .get(&builtin_attributes.color) + .and_then(|&e| p_particle_buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let normal = particles_data + .buffers + .get(&builtin_attributes.normal) + .and_then(|&e| p_particle_buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let render_layers = batch.render_layers.clone(); + + flush_batch(&mut res, &mut batch, &p_material_handles); + + let raster_draw = crate::particles::point_render::ParticleRasterDraw { + position, + count, + topology, + index, + indirect, + color, + normal, + blend: state.style.blend_state, + }; + match particles_data.raster_draw_entity { + Some(e) => { + res.commands.entity(e).insert(( + raster_draw, + render_layers, + Visibility::Visible, + )); + } + None => { + let e = res + .commands + .spawn(( + raster_draw, + Visibility::Visible, + Transform::default(), + NoFrustumCulling, + render_layers, + )) + .id(); + particles_data.raster_draw_entity = Some(e); + } + } + + batch.draw_index += 1; + } + DrawCommand::Particles { + particles, + geometry: Some(geometry), + topology: _, + } => { + let Some((geometry_data, _)) = p_geometries.get(geometry).ok() else { + warn!("Could not find Geometry for entity {:?}", geometry); + continue; + }; + let Ok(mut particles_data) = p_particles.get_mut(particles) else { + warn!("Could not find Particles for entity {:?}", particles); + continue; + }; + + let material_handle = if let Fill::Buffer(buf_entity) = state.style.fill { + match particles_fill_material(&mut res, buf_entity) { + Some(h) => h, + None => { + warn!("fill(buffer) entity {:?} not found", buf_entity); + continue; + } + } + } else { + let material_key = material_key_with_fill(&state); + match &material_key { + MaterialKey::Custom { + entity: mat_entity, + blend_state, + } => { + let Some(untyped) = p_material_handles.get(*mat_entity).ok() else { + warn!("Could not find material for entity {:?}", mat_entity); + continue; + }; + clone_custom_material_with_blend( + &mut res.custom_materials, + &untyped.0, + *blend_state, + ) + } + _ => material_key.to_material(&mut res.materials), + } + }; + + flush_batch(&mut res, &mut batch, &p_material_handles); + + let mesh_handle = geometry_data.handle.clone(); + let capacity = particles_data.capacity; + let render_layers = batch.render_layers.clone(); + match particles_data.draw_entity { + Some(e) => { + res.commands.entity(e).insert(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + render_layers, + Visibility::Visible, + )); + } + None => { + let e = res + .commands + .spawn(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + Aabb { + center: Vec3A::ZERO, + half_extents: Vec3A::splat(1000.0), + }, + ParticlesDraw { particles }, + render_layers, + Visibility::Visible, + )) + .id(); + particles_data.draw_entity = Some(e); + } + } + + batch.draw_index += 1; + } DrawCommand::BlendMode(blend_state) => { - state.blend_state = blend_state; + state.style.blend_state = blend_state; } DrawCommand::Material(entity) => { - state.material_key = MaterialKey::Custom { + state.style.material_key = MaterialKey::Custom { entity, blend_state: None, }; @@ -1016,6 +1236,138 @@ pub fn flush_draw_commands( &p_material_handles, ); } + DrawCommand::TextFont(font_entity) => { + if let Some(entity) = font_entity { + if let Ok(font) = p_fonts.get(entity) { + state.style.text_font_family = Some(font.family_name.clone()); + } + } else { + state.style.text_font_family = None; + } + } + DrawCommand::TextStyle(style) => { + state.style.text_style = style; + } + DrawCommand::TextWeight(weight) => { + state.style.text_weight = Some(weight); + } + DrawCommand::TextVariation { tag, value } => { + if let Some(existing) = state + .style + .text_variations + .iter_mut() + .find(|(t, _)| *t == tag) + { + existing.1 = value; + } else { + state.style.text_variations.push((tag, value)); + } + } + DrawCommand::ClearTextVariations => { + state.style.text_variations.clear(); + } + DrawCommand::TextFeature { tag, value } => { + if let Some(existing) = state + .style + .text_features + .iter_mut() + .find(|(t, _)| *t == tag) + { + existing.1 = value; + } else { + state.style.text_features.push((tag, value)); + } + } + DrawCommand::NoTextFeature { tag } => { + state.style.text_features.retain(|(t, _)| *t != tag); + } + DrawCommand::ClearTextFeatures => { + state.style.text_features.clear(); + } + DrawCommand::TextSize(size) => { + state.style.text_size = size; + state.style.text_leading = None; + } + DrawCommand::TextAlign { h, v } => { + state.style.text_align_h = h; + state.style.text_align_v = v; + } + DrawCommand::TextLeading(leading) => { + state.style.text_leading = Some(leading); + } + DrawCommand::TextWrap(mode) => { + state.style.text_wrap = mode; + } + DrawCommand::TextGlyphColors(colors) => { + state.style.text_glyph_colors = Some(colors); + } + DrawCommand::Text { + content, + x, + y, + z, + max_w, + max_h, + } => { + // rectMode applies to the bounding-box form + let (x, y, max_w, max_h) = if let (Some(w), Some(h)) = (max_w, max_h) { + let (bx, by, bw, bh) = apply_shape_mode(state.style.rect_mode, x, y, w, h); + (bx, by, Some(bw), Some(bh)) + } else { + (x, y, max_w, max_h) + }; + + let mut text_params = + primitive::text::OwnedTextParams::from_render_state(&state, max_w, max_h); + // per-glyph colors apply to this one text() call only + text_params.glyph_colors = state.style.text_glyph_colors.take(); + let text_cx = text_cx.clone(); + + if z != 0.0 { + state + .transform + .apply(Affine3A::from_translation(Vec3::new(0.0, 0.0, z))); + } + + add_fill( + &mut res, + &mut batch, + &state, + |mesh, color| { + primitive::text::text( + mesh, + &content, + x, + y, + color, + &text_params.as_params(), + &text_cx, + ); + }, + &p_material_handles, + ); + + add_stroke( + &mut res, + &mut batch, + &state, + |mesh, color, weight| { + // per-glyph fill colors don't apply to the stroke + let mut params = text_params.as_params(); + params.glyph_colors = None; + primitive::text::text_stroke( + mesh, &content, x, y, color, weight, ¶ms, &text_cx, + ); + }, + &p_material_handles, + ); + + if z != 0.0 { + state + .transform + .apply(Affine3A::from_translation(Vec3::new(0.0, 0.0, -z))); + } + } } } @@ -1087,7 +1439,7 @@ fn spawn_mesh( fn needs_batch(batch: &BatchState, state: &RenderState, material_key: &MaterialKey) -> bool { let material_changed = batch.material_key.as_ref() != Some(material_key); let transform_changed = batch.transform != state.transform.current(); - let requires_separate_draws = state.blend_state.is_some(); + let requires_separate_draws = state.style.blend_state.is_some(); material_changed || transform_changed || requires_separate_draws } @@ -1141,26 +1493,20 @@ fn material_key_with_color( ) -> MaterialKey { match key { MaterialKey::Color { - background_image, .. + background_image, + uv_transform, + .. } => MaterialKey::Color { transparent: color.alpha() < 1.0, background_image: background_image.clone(), + uv_transform: *uv_transform, blend_state, }, - MaterialKey::Pbr { - roughness, - metallic, - emissive, - .. - } => { - let [r, g, b, a] = color.to_srgba().to_u8_array(); - MaterialKey::Pbr { - albedo: [r, g, b, a], - roughness: *roughness, - metallic: *metallic, - emissive: *emissive, - blend_state, - } + MaterialKey::Pbr { .. } => { + let mut pbr = key.as_pbr(); + pbr.albedo = color.to_srgba().to_u8_array(); + pbr.blend_state = blend_state; + pbr.into() } MaterialKey::Custom { entity, .. } => MaterialKey::Custom { entity: *entity, @@ -1169,9 +1515,33 @@ fn material_key_with_color( } } +/// new `ParticlesMaterial` per call; not cached because per-frame alloc is cheap. +fn particles_fill_material( + res: &mut RenderResources, + buf_entity: Entity, +) -> Option { + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; + + let buf = res.particle_buffers.get(buf_entity).ok()?; + let handle = res.particles_materials.add(ParticlesMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 0.4, + metallic: 0.0, + cull_mode: None, + ..Default::default() + }, + extension: ParticlesExtension { + colors: Some(buf.handle.clone()), + emissive_colors: None, + }, + }); + Some(handle.untyped()) +} + fn material_key_with_fill(state: &RenderState) -> MaterialKey { - let color = state.fill_color.unwrap_or(Color::WHITE); - material_key_with_color(&state.material_key, color, state.blend_state) + let color = state.style.fill.color().unwrap_or(Color::WHITE); + material_key_with_color(&state.style.material_key, color, state.style.blend_state) } fn add_fill( @@ -1181,10 +1551,11 @@ fn add_fill( tessellate: impl FnOnce(&mut Mesh, Color), material_handles: &Query<&UntypedMaterial>, ) { - let Some(color) = state.fill_color else { + let Some(color) = state.style.fill.color() else { return; }; - let material_key = material_key_with_color(&state.material_key, color, state.blend_state); + let material_key = + material_key_with_color(&state.style.material_key, color, state.style.blend_state); if needs_batch(batch, state, &material_key) { start_batch(res, batch, state, material_key, material_handles); @@ -1202,11 +1573,12 @@ fn add_stroke( tessellate: impl FnOnce(&mut Mesh, Color, f32), material_handles: &Query<&UntypedMaterial>, ) { - let Some(color) = state.stroke_color else { + let Some(color) = state.style.stroke_color else { return; }; - let stroke_weight = state.stroke_weight; - let material_key = material_key_with_color(&state.material_key, color, state.blend_state); + let stroke_weight = state.style.stroke_weight; + let material_key = + material_key_with_color(&state.style.material_key, color, state.style.blend_state); if needs_batch(batch, state, &material_key) { start_batch(res, batch, state, material_key, material_handles); @@ -1242,8 +1614,8 @@ fn add_shape3d( flush_batch(res, batch, material_handles); let mesh_handle = res.meshes.add(mesh); - let fill_color = state.fill_color.unwrap_or(Color::WHITE); - let material_handle = match &state.material_key { + let fill_color = state.style.fill.color().unwrap_or(Color::WHITE); + let material_handle = match &state.style.material_key { MaterialKey::Custom { entity, .. } => { let Some(untyped) = material_handles.get(*entity).ok() else { warn!("Custom material entity {:?} not found", entity); @@ -1252,7 +1624,7 @@ fn add_shape3d( clone_custom_material_with_blend( &mut res.custom_materials, &untyped.0, - state.blend_state, + state.style.blend_state, ) } // TODO: in 2d, we use vertex colors. `to_material` becomes complicated if we also encode @@ -1263,7 +1635,7 @@ fn add_shape3d( base_color: fill_color, unlit: true, cull_mode: None, - alpha_mode: if state.blend_state.is_some() || *transparent { + alpha_mode: if state.style.blend_state.is_some() || *transparent { AlphaMode::Blend } else { AlphaMode::Opaque @@ -1273,7 +1645,8 @@ fn add_shape3d( let extended = ProcessingExtendedMaterial { base, extension: ProcessingMaterial { - blend_state: state.blend_state, + blend_state: state.style.blend_state, + depth_write: None, }, }; res.materials.add(extended).untyped() @@ -1296,14 +1669,14 @@ fn add_shape3d( batch.render_layers.clone(), )); - if let Some(stroke_color) = state.stroke_color { + if let Some(stroke_color) = state.style.stroke_color { entity.insert(( Wireframe, WireframeColor { color: stroke_color, }, WireframeLineWidth { - width: state.stroke_weight, + width: state.style.stroke_weight, }, WireframeTopology::Quads, )); @@ -1312,9 +1685,8 @@ fn add_shape3d( batch.draw_index += 1; } -/// Creates a fullscreen quad by transforming NDC fullscreen by inverse of the clip-from-world matrix -/// so that when the vertex shader applies clip_from_world, the vertices end up correctly back in -/// NDC space. +/// fullscreen quad built by transforming NDC corners by the inverse clip-from-world matrix, +/// so the vertex shader's `clip_from_world` brings them back to NDC. fn create_ndc_background_quad(world_from_clip: Mat4, color: Color, with_uvs: bool) -> Mesh { use bevy::asset::RenderAssetUsages; use bevy::mesh::{Indices, PrimitiveTopology}; @@ -1345,7 +1717,6 @@ fn create_ndc_background_quad(world_from_clip: Mat4, color: Color, with_uvs: boo let color_array: [f32; 4] = color.to_linear().to_f32_array(); let colors: Vec<[f32; 4]> = vec![color_array; 4]; - // two tris let indices: Vec = vec![0, 1, 2, 0, 2, 3]; let mut mesh = Mesh::new( diff --git a/crates/processing_render/src/render/primitive/mod.rs b/crates/processing_render/src/render/primitive/mod.rs index 8a3649fe..c4f20d63 100644 --- a/crates/processing_render/src/render/primitive/mod.rs +++ b/crates/processing_render/src/render/primitive/mod.rs @@ -6,6 +6,7 @@ mod quad; mod rect; mod shape; mod shape3d; +pub mod text; mod triangle; pub use arc::{arc_fill, arc_stroke}; @@ -30,7 +31,7 @@ pub use shape::{ build_polygon_stroke, }; pub use shape3d::{ - box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, plane_mesh, + box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, grid_mesh, plane_mesh, sphere_mesh, tetrahedron_mesh, torus_mesh, }; pub use triangle::triangle; diff --git a/crates/processing_render/src/render/primitive/shape3d.rs b/crates/processing_render/src/render/primitive/shape3d.rs index ac059a71..1e4889b3 100644 --- a/crates/processing_render/src/render/primitive/shape3d.rs +++ b/crates/processing_render/src/render/primitive/shape3d.rs @@ -1,7 +1,8 @@ +use bevy::asset::RenderAssetUsages; +use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use bevy::render::mesh::VertexAttributeValues; -/// Ensure a mesh has vertex colors, inserting default white if missing. fn ensure_vertex_colors(mesh: &mut Mesh) { if mesh.attribute(Mesh::ATTRIBUTE_COLOR).is_none() { let vertex_count = mesh @@ -89,6 +90,37 @@ pub fn tetrahedron_mesh(radius: f32) -> Mesh { mesh } +pub fn grid_mesh(nx: u32, ny: u32, nz: u32, spacing: f32) -> Mesh { + let count = (nx as usize) * (ny as usize) * (nz as usize); + let mut positions = Vec::with_capacity(count); + let mut uvs = Vec::with_capacity(count); + + let half_x = (nx as f32 - 1.0) * 0.5 * spacing; + let half_y = (ny as f32 - 1.0) * 0.5 * spacing; + let half_z = (nz as f32 - 1.0) * 0.5 * spacing; + let inv_x = if nx > 1 { 1.0 / (nx as f32 - 1.0) } else { 0.0 }; + let inv_y = if ny > 1 { 1.0 / (ny as f32 - 1.0) } else { 0.0 }; + + for ix in 0..nx { + for iy in 0..ny { + for iz in 0..nz { + positions.push([ + ix as f32 * spacing - half_x, + iy as f32 * spacing - half_y, + iz as f32 * spacing - half_z, + ]); + uvs.push([ix as f32 * inv_x, iy as f32 * inv_y]); + } + } + } + + let mut mesh = Mesh::new(PrimitiveTopology::PointList, RenderAssetUsages::all()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + ensure_vertex_colors(&mut mesh); + mesh +} + pub fn plane_mesh(width: f32, height: f32) -> Mesh { let plane = bevy::math::primitives::Plane3d::default(); let mut mesh = plane.mesh().size(width, height).build(); diff --git a/crates/processing_render/src/render/primitive/text.rs b/crates/processing_render/src/render/primitive/text.rs new file mode 100644 index 00000000..5cc4ab73 --- /dev/null +++ b/crates/processing_render/src/render/primitive/text.rs @@ -0,0 +1,1291 @@ +use std::borrow::Cow; + +use bevy::mesh::{Indices, VertexAttributeValues}; +use bevy::prelude::*; +use lyon::{ + geom::Point, + path::Path, + tessellation::{ + FillOptions, FillTessellator, FillVertex, StrokeOptions, StrokeTessellator, VertexId, + geometry_builder::{FillGeometryBuilder, GeometryBuilder, GeometryBuilderError}, + }, +}; +use parley::{ + Alignment, AlignmentOptions, FontContext, Layout, LayoutContext, PositionedLayoutItem, + StyleProperty, + style::{ + FontFamily, FontFeature, FontSettings, FontStack, FontStyle as ParleyFontStyle, + FontVariation, FontWeight as ParleyFontWeight, LineHeight, WordBreakStrength, + }, +}; +use skrifa::{ + FontRef, MetadataProvider, + instance::{LocationRef, NormalizedCoord, Size}, + outline::{DrawSettings, OutlinePen}, +}; + +use crate::render::{ + RenderState, + command::{TextAlignH, TextAlignV, TextStyle, TextWrapMode}, + mesh_builder::MeshBuilder, +}; +use crate::text::font::{DEFAULT_FONT_FAMILY, TextContext}; + +/// A path command for text outline data. +#[derive(Debug, Clone)] +pub enum PathCommand { + MoveTo(f32, f32), + LineTo(f32, f32), + QuadTo { + cx: f32, + cy: f32, + x: f32, + y: f32, + }, + CubicTo { + cx1: f32, + cy1: f32, + cx2: f32, + cy2: f32, + x: f32, + y: f32, + }, + Close, +} + +/// Text layout parameters. +pub struct TextParams<'a> { + pub text_size: f32, + pub align_h: TextAlignH, + pub align_v: TextAlignV, + pub leading: Option, + pub max_w: Option, + pub max_h: Option, + pub wrap: TextWrapMode, + pub font_family: Option<&'a str>, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: &'a [([u8; 4], f32)], + pub text_features: &'a [([u8; 4], u16)], + pub glyph_colors: Option<&'a [Color]>, +} + +/// Owned [`TextParams`]: a `RenderState` snapshot that outlives the borrow. +pub struct OwnedTextParams { + pub text_size: f32, + pub align_h: TextAlignH, + pub align_v: TextAlignV, + pub leading: Option, + pub max_w: Option, + pub max_h: Option, + pub wrap: TextWrapMode, + pub font_family: Option, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: Vec<([u8; 4], f32)>, + pub text_features: Vec<([u8; 4], u16)>, + pub glyph_colors: Option>, +} + +impl OwnedTextParams { + /// Snapshot a `RenderState`'s text state. `glyph_colors` is left unset; the + /// draw path fills it in, measurement queries don't need it. + pub fn from_render_state(state: &RenderState, max_w: Option, max_h: Option) -> Self { + Self { + text_size: state.style.text_size, + align_h: state.style.text_align_h, + align_v: state.style.text_align_v, + leading: state.style.text_leading, + max_w, + max_h, + wrap: state.style.text_wrap, + font_family: state.style.text_font_family.clone(), + text_style: state.style.text_style, + text_weight: state.style.text_weight, + text_variations: state.style.text_variations.clone(), + text_features: state.style.text_features.clone(), + glyph_colors: None, + } + } + + /// Borrow as a [`TextParams`]. + pub fn as_params(&self) -> TextParams<'_> { + TextParams { + text_size: self.text_size, + align_h: self.align_h, + align_v: self.align_v, + leading: self.leading, + max_w: self.max_w, + max_h: self.max_h, + wrap: self.wrap, + font_family: self.font_family.as_deref(), + text_style: self.text_style, + text_weight: self.text_weight, + text_variations: &self.text_variations, + text_features: &self.text_features, + glyph_colors: self.glyph_colors.as_deref(), + } + } +} + +/// Tessellate text into a mesh (fill). +pub fn text( + mesh: &mut Mesh, + content: &str, + x: f32, + y: f32, + color: Color, + params: &TextParams, + text_cx: &TextContext, +) { + if content.is_empty() { + return; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, color, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + tessellate_layout( + mesh, + &layout, + base_x, + base_y, + params.max_h, + params.glyph_colors, + ); + }); +} + +/// Tessellate text outlines as strokes into a mesh. +pub fn text_stroke( + mesh: &mut Mesh, + content: &str, + x: f32, + y: f32, + color: Color, + stroke_weight: f32, + params: &TextParams, + text_cx: &TextContext, +) { + if content.is_empty() { + return; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, color, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + stroke_layout( + mesh, + &layout, + base_x, + base_y, + color, + stroke_weight, + params.max_h, + ); + }); +} + +/// Measure the width of text. +pub fn text_width(content: &str, params: &TextParams, text_cx: &TextContext) -> f32 { + if content.is_empty() { + return 0.0; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + + let mut max_width: f32 = 0.0; + for line_idx in 0..layout.len() { + if let Some(line) = layout.get(line_idx) { + max_width = max_width.max(line.metrics().advance); + } + } + max_width + }) +} + +/// Font ascent for the current text size. +pub fn text_ascent(params: &TextParams, text_cx: &TextContext) -> f32 { + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); + layout + .get(0) + .map(|line| line.metrics().ascent) + .unwrap_or(0.0) + }) +} + +/// Font descent for the current text size. +pub fn text_descent(params: &TextParams, text_cx: &TextContext) -> f32 { + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, "X", Color::BLACK, params); + layout + .get(0) + .map(|line| line.metrics().descent) + .unwrap_or(0.0) + }) +} + +/// Bounding box of text as `[x, y, width, height]`. +pub fn text_bounds( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> [f32; 4] { + if content.is_empty() { + return [x, y, 0.0, 0.0]; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + + // the text origin is the layout's top-left corner + let (box_x, box_y) = compute_text_origin(&layout, x, y, params.align_v); + let width = layout.width(); + let height = match params.max_h { + Some(h) => layout.height().min(h), + None => layout.height(), + }; + + [box_x, box_y, width, height] + }) +} + +/// A single laid-out line. +#[derive(Debug, Clone)] +pub struct TextLineInfo { + pub text: String, + /// `[x, y, width, height]`. + pub rect: [f32; 4], +} + +/// A single laid-out glyph. +#[derive(Debug, Clone)] +pub struct TextGlyphInfo { + /// `[x, y, width, height]`. + pub rect: [f32; 4], +} + +/// Number of lines after layout. +pub fn text_line_count(content: &str, params: &TextParams, text_cx: &TextContext) -> usize { + if content.is_empty() { + return 0; + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + layout.len() + }) +} + +/// Per-line text and bounding rects. +pub fn text_lines( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec { + if content.is_empty() { + return Vec::new(); + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let mut result = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + let metrics = line.metrics(); + + if let Some(h) = params.max_h + && metrics.baseline + metrics.descent > h + { + break; + } + + let line_y = base_y + metrics.baseline - metrics.ascent; + let line_text = &content[line.text_range()]; + + result.push(TextLineInfo { + text: line_text.to_string(), + rect: [ + base_x, + line_y, + metrics.advance, + metrics.ascent + metrics.descent, + ], + }); + } + result + }) +} + +/// Per-glyph bounding rects. +pub fn text_glyph_rects( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec { + if content.is_empty() { + return Vec::new(); + } + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let mut result = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + let metrics = line.metrics(); + + if let Some(h) = params.max_h + && metrics.baseline + metrics.descent > h + { + break; + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + let run = glyph_run.run(); + let font_size = run.font_size(); + for glyph in glyph_run.positioned_glyphs() { + let gx = base_x + glyph.x; + let gy = base_y + glyph.y - metrics.ascent; + result.push(TextGlyphInfo { + rect: [gx, gy, glyph.advance, font_size], + }); + } + } + } + result + }) +} + +/// Extract glyph outlines as path commands (one vec per glyph). +pub fn text_to_paths( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + extract_glyph_path_commands(&layout, base_x, base_y, params.max_h) + }) +} + +/// Default `sample_factor` for [`text_to_points`]. +pub const DEFAULT_SAMPLE_FACTOR: f32 = 0.1; + +/// Sample points along text outlines. Higher `sample_factor` = more points; +/// see [`DEFAULT_SAMPLE_FACTOR`]. +pub fn text_to_points( + content: &str, + x: f32, + y: f32, + sample_factor: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec<[f32; 2]> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let glyph_paths = extract_glyph_lyon_paths(&layout, base_x, base_y, params.max_h); + + let step = sample_factor.max(0.001); + let mut points = Vec::new(); + + for path in &glyph_paths { + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + points.push([at.x, at.y]); + } + Event::Line { from, to } => { + let dx = to.x - from.x; + let dy = to.y - from.y; + let len = (dx * dx + dy * dy).sqrt(); + let steps = (len * step).max(1.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + points.push([from.x + dx * t, from.y + dy * t]); + } + } + Event::Quadratic { from, ctrl, to } => { + let steps = (20.0 * step).max(2.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x; + let py = inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y; + points.push([px, py]); + } + } + Event::Cubic { + from, + ctrl1, + ctrl2, + to, + } => { + let steps = (30.0 * step).max(2.0) as usize; + for i in 1..=steps { + let t = i as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * inv * from.x + + 3.0 * inv * inv * t * ctrl1.x + + 3.0 * inv * t * t * ctrl2.x + + t * t * t * to.x; + let py = inv * inv * inv * from.y + + 3.0 * inv * inv * t * ctrl1.y + + 3.0 * inv * t * t * ctrl2.y + + t * t * t * to.y; + points.push([px, py]); + } + } + Event::End { .. } => {} + } + } + } + + points + }) +} + +/// 3D extruded mesh from text outlines: front and back faces plus side walls, +/// in Bevy's Y-up convention. +pub fn text_to_model( + content: &str, + x: f32, + y: f32, + depth: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Mesh { + let mut mesh = empty_mesh(); + + if content.is_empty() || depth <= 0.0 { + return mesh; + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::WHITE, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let glyph_paths = extract_glyph_lyon_paths_yup(&layout, base_x, base_y, params.max_h); + + let half_depth = depth / 2.0; + let mut fill_tess = FillTessellator::new(); + + for path in &glyph_paths { + // front face: z = +half_depth, normal +Z + { + let mut builder = Extrusion3DBuilder::new(&mut mesh, half_depth, [0.0, 0.0, 1.0]); + let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); + } + + // back face: z = -half_depth, normal -Z, with winding reversed below + let back_indices_start = mesh + .indices() + .map(|i| match i { + Indices::U32(v) => v.len(), + _ => 0, + }) + .unwrap_or(0); + { + let mut builder = Extrusion3DBuilder::new(&mut mesh, -half_depth, [0.0, 0.0, -1.0]); + let _ = fill_tess.tessellate_path(path, &FillOptions::default(), &mut builder); + } + + if let Some(Indices::U32(indices)) = mesh.indices_mut() { + let mut i = back_indices_start; + while i + 2 < indices.len() { + indices.swap(i + 1, i + 2); + i += 3; + } + } + + // side walls: connect each contour's front vertices to its back ones + let mut contour_points: Vec> = Vec::new(); + + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + contour_points.clear(); + contour_points.push(at); + } + Event::Line { from: _, to } => { + contour_points.push(to); + } + Event::Quadratic { from, ctrl, to } => { + let steps = 8; + for s in 1..=steps { + let t = s as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * from.x + 2.0 * inv * t * ctrl.x + t * t * to.x; + let py = inv * inv * from.y + 2.0 * inv * t * ctrl.y + t * t * to.y; + contour_points.push(Point::new(px, py)); + } + } + Event::Cubic { + from, + ctrl1, + ctrl2, + to, + } => { + let steps = 12; + for s in 1..=steps { + let t = s as f32 / steps as f32; + let inv = 1.0 - t; + let px = inv * inv * inv * from.x + + 3.0 * inv * inv * t * ctrl1.x + + 3.0 * inv * t * t * ctrl2.x + + t * t * t * to.x; + let py = inv * inv * inv * from.y + + 3.0 * inv * inv * t * ctrl1.y + + 3.0 * inv * t * t * ctrl2.y + + t * t * t * to.y; + contour_points.push(Point::new(px, py)); + } + } + Event::End { close, .. } => { + if close && contour_points.len() >= 2 { + for i in 0..contour_points.len() { + let j = (i + 1) % contour_points.len(); + let p0 = contour_points[i]; + let p1 = contour_points[j]; + + // outward normal of this edge + let dx = p1.x - p0.x; + let dy = p1.y - p0.y; + let len = (dx * dx + dy * dy).sqrt().max(1e-6); + let nx = -dy / len; + let ny = dx / len; + let normal = [nx, ny, 0.0]; + + // quad vertices: front-p0, front-p1, back-p1, back-p0 + let base = vertex_count(&mesh) as u32; + push_vertex_3d(&mut mesh, [p0.x, p0.y, half_depth], normal); + push_vertex_3d(&mut mesh, [p1.x, p1.y, half_depth], normal); + push_vertex_3d(&mut mesh, [p1.x, p1.y, -half_depth], normal); + push_vertex_3d(&mut mesh, [p0.x, p0.y, -half_depth], normal); + + if let Some(Indices::U32(indices)) = mesh.indices_mut() { + indices.extend_from_slice(&[ + base, + base + 1, + base + 2, + base, + base + 2, + base + 3, + ]); + } + } + } + contour_points.clear(); + } + } + } + } + }); + + mesh +} + +fn empty_mesh() -> Mesh { + use bevy::render::mesh::PrimitiveTopology; + let mut mesh = Mesh::new(PrimitiveTopology::TriangleList, default()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, Vec::<[f32; 3]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, Vec::<[f32; 3]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_COLOR, Vec::<[f32; 4]>::new()); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, Vec::<[f32; 2]>::new()); + mesh.insert_indices(Indices::U32(Vec::new())); + mesh +} + +fn vertex_count(mesh: &Mesh) -> usize { + mesh.attribute(Mesh::ATTRIBUTE_POSITION) + .map(|a| match a { + VertexAttributeValues::Float32x3(v) => v.len(), + _ => 0, + }) + .unwrap_or(0) +} + +fn push_vertex_3d(mesh: &mut Mesh, position: [f32; 3], normal: [f32; 3]) { + if let Some(VertexAttributeValues::Float32x3(positions)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_POSITION) + { + positions.push(position); + } + if let Some(VertexAttributeValues::Float32x3(normals)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_NORMAL) + { + normals.push(normal); + } + if let Some(VertexAttributeValues::Float32x4(colors)) = + mesh.attribute_mut(Mesh::ATTRIBUTE_COLOR) + { + colors.push([1.0, 1.0, 1.0, 1.0]); + } + if let Some(VertexAttributeValues::Float32x2(uvs)) = mesh.attribute_mut(Mesh::ATTRIBUTE_UV_0) { + uvs.push([0.0, 0.0]); + } +} + +/// Places lyon fill output at a fixed Z depth and normal. +struct Extrusion3DBuilder<'a> { + mesh: &'a mut Mesh, + z: f32, + normal: [f32; 3], + begin_vertex_count: u32, +} + +impl<'a> Extrusion3DBuilder<'a> { + fn new(mesh: &'a mut Mesh, z: f32, normal: [f32; 3]) -> Self { + Self { + mesh, + z, + normal, + begin_vertex_count: 0, + } + } +} + +impl<'a> GeometryBuilder for Extrusion3DBuilder<'a> { + fn begin_geometry(&mut self) { + self.begin_vertex_count = vertex_count(self.mesh) as u32; + } + fn add_triangle(&mut self, a: VertexId, b: VertexId, c: VertexId) { + if let Some(Indices::U32(indices)) = self.mesh.indices_mut() { + indices.push(a.to_usize() as u32); + indices.push(b.to_usize() as u32); + indices.push(c.to_usize() as u32); + } + } + fn abort_geometry(&mut self) {} +} + +impl<'a> FillGeometryBuilder for Extrusion3DBuilder<'a> { + fn add_fill_vertex(&mut self, vertex: FillVertex) -> Result { + let pos = vertex.position(); + let count = vertex_count(self.mesh); + push_vertex_3d(self.mesh, [pos.x, pos.y, self.z], self.normal); + Ok(VertexId::from_usize(count)) + } +} + +/// Absolute position of the layout's top-left corner — parley's glyph +/// positions are measured against it. `y` is the first line's baseline for +/// `Baseline` align, otherwise the top/center/bottom of the text block. +fn compute_text_origin(layout: &Layout, x: f32, y: f32, align_v: TextAlignV) -> (f32, f32) { + let total_height = layout.height(); + let first_baseline = layout + .get(0) + .map(|line| line.metrics().baseline) + .unwrap_or(0.0); + + let y_offset = match align_v { + TextAlignV::Baseline => y - first_baseline, + TextAlignV::Top => y, + TextAlignV::Center => y - total_height / 2.0, + TextAlignV::Bottom => y - total_height, + }; + + (x, y_offset) +} + +/// Extract text outlines, one `PathCommand` vec per contour. +pub fn text_to_contours( + content: &str, + x: f32, + y: f32, + params: &TextParams, + text_cx: &TextContext, +) -> Vec> { + if content.is_empty() { + return Vec::new(); + } + + text_cx.with(|font_cx, layout_cx| { + let layout = build_layout(font_cx, layout_cx, content, Color::BLACK, params); + let (base_x, base_y) = compute_text_origin(&layout, x, y, params.align_v); + let lyon_paths = extract_glyph_lyon_paths(&layout, base_x, base_y, params.max_h); + + let mut contours = Vec::new(); + for path in &lyon_paths { + let mut current_contour = Vec::new(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + current_contour.push(PathCommand::MoveTo(at.x, at.y)); + } + Event::Line { from: _, to } => { + current_contour.push(PathCommand::LineTo(to.x, to.y)); + } + Event::Quadratic { from: _, ctrl, to } => { + current_contour.push(PathCommand::QuadTo { + cx: ctrl.x, + cy: ctrl.y, + x: to.x, + y: to.y, + }); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + current_contour.push(PathCommand::CubicTo { + cx1: ctrl1.x, + cy1: ctrl1.y, + cx2: ctrl2.x, + cy2: ctrl2.y, + x: to.x, + y: to.y, + }); + } + Event::End { close, .. } => { + if close { + current_contour.push(PathCommand::Close); + } + if !current_contour.is_empty() { + contours.push(std::mem::take(&mut current_contour)); + } + } + } + } + if !current_contour.is_empty() { + contours.push(current_contour); + } + } + contours + }) +} + +/// Extract glyph outlines as PathCommand vecs. +fn extract_glyph_path_commands( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec> { + let lyon_paths = extract_glyph_lyon_paths(layout, base_x, base_y, max_h); + lyon_paths + .into_iter() + .map(|path| { + let mut cmds = Vec::new(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => cmds.push(PathCommand::MoveTo(at.x, at.y)), + Event::Line { from: _, to } => cmds.push(PathCommand::LineTo(to.x, to.y)), + Event::Quadratic { from: _, ctrl, to } => { + cmds.push(PathCommand::QuadTo { + cx: ctrl.x, + cy: ctrl.y, + x: to.x, + y: to.y, + }); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + cmds.push(PathCommand::CubicTo { + cx1: ctrl1.x, + cy1: ctrl1.y, + cx2: ctrl2.x, + cy2: ctrl2.y, + x: to.x, + y: to.y, + }); + } + Event::End { close, .. } => { + if close { + cmds.push(PathCommand::Close); + } + } + } + } + cmds + }) + .collect() +} + +/// Extract glyph outlines as lyon `Path`s, one per glyph. +fn extract_glyph_lyon_paths( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec { + let mut paths = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + for glyph in glyph_run.positioned_glyphs() { + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + paths.push(translate_path_flip_y(&path, tx, ty)); + } + } + } + } + } + + paths +} + +fn build_layout( + font_cx: &mut FontContext, + layout_cx: &mut LayoutContext, + content: &str, + color: Color, + params: &TextParams, +) -> Layout { + let mut builder = layout_cx.ranged_builder(font_cx, content, 1.0, false); + + let family = params.font_family.unwrap_or(DEFAULT_FONT_FAMILY); + builder.push_default(StyleProperty::FontSize(params.text_size)); + builder.push_default(StyleProperty::FontStack(FontStack::Single( + FontFamily::Named(Cow::Owned(family.to_string())), + ))); + builder.push_default(StyleProperty::Brush(color)); + + if let Some(line_height) = params.leading { + builder.push_default(StyleProperty::LineHeight(LineHeight::Absolute(line_height))); + } + + if matches!(params.wrap, TextWrapMode::Char) { + builder.push_default(StyleProperty::WordBreak(WordBreakStrength::BreakAll)); + } + + // text_weight overrides the bold implied by text_style + if let Some(weight) = params.text_weight { + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::new(weight))); + if matches!(params.text_style, TextStyle::Italic | TextStyle::BoldItalic) { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + } + } else { + match params.text_style { + TextStyle::Normal => {} + TextStyle::Italic => { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + } + TextStyle::Bold => { + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::BOLD)); + } + TextStyle::BoldItalic => { + builder.push_default(StyleProperty::FontStyle(ParleyFontStyle::Italic)); + builder.push_default(StyleProperty::FontWeight(ParleyFontWeight::BOLD)); + } + } + } + + if !params.text_variations.is_empty() { + let vars: Vec = params + .text_variations + .iter() + .map(|&(tag, value)| FontVariation { + tag: u32::from_be_bytes(tag), + value, + }) + .collect(); + builder.push_default(StyleProperty::FontVariations(FontSettings::List( + Cow::Owned(vars), + ))); + } + + if !params.text_features.is_empty() { + let feats: Vec = params + .text_features + .iter() + .map(|&(tag, value)| FontFeature { + tag: u32::from_be_bytes(tag), + value, + }) + .collect(); + builder.push_default(StyleProperty::FontFeatures(FontSettings::List(Cow::Owned( + feats, + )))); + } + + let mut layout = builder.build(content); + + let max_advance = params.max_w.unwrap_or(f32::MAX); + layout.break_all_lines(Some(max_advance)); + + let alignment = match params.align_h { + TextAlignH::Left => Alignment::Start, + TextAlignH::Center => Alignment::Center, + TextAlignH::Right => Alignment::End, + }; + layout.align(params.max_w, alignment, AlignmentOptions::default()); + + layout +} + +fn tessellate_layout( + mesh: &mut Mesh, + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, + glyph_colors: Option<&[Color]>, +) { + let mut fill_tess = FillTessellator::new(); + let mut glyph_index: usize = 0; + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + // stop once a line falls past max_h + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + let color = glyph_run.style().brush; + + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + + // parley's i16 normalized coords -> skrifa's F2Dot14 NormalizedCoord + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + for glyph in glyph_run.positioned_glyphs() { + let glyph_color = glyph_colors + .filter(|colors| !colors.is_empty()) + .map(|colors| colors[glyph_index % colors.len()]) + .unwrap_or(color); + glyph_index += 1; + + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + // font outlines are Y-up; translate_path_flip_y flips to Y-down + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + + let translated = translate_path_flip_y(&path, tx, ty); + + let mut builder = MeshBuilder::new(mesh, glyph_color); + let _ = fill_tess.tessellate_path( + &translated, + &FillOptions::default(), + &mut builder, + ); + } + } + } + } + } +} + +fn stroke_layout( + mesh: &mut Mesh, + layout: &Layout, + base_x: f32, + base_y: f32, + color: Color, + stroke_weight: f32, + max_h: Option, +) { + let mut stroke_tess = StrokeTessellator::new(); + let stroke_opts = StrokeOptions::default().with_line_width(stroke_weight); + + let glyph_paths = extract_glyph_lyon_paths(layout, base_x, base_y, max_h); + for path in &glyph_paths { + let mut builder = MeshBuilder::new(mesh, color); + let _ = stroke_tess.tessellate_path(path, &stroke_opts, &mut builder); + } +} + +/// Translate a lyon path keeping Y-up convention (for 3D geometry). +/// Font outline Y is up; layout ty is Y-down, so we compute: (x + tx, y - ty). +fn translate_path_yup(path: &Path, tx: f32, ty: f32) -> Path { + let mut builder = Path::builder(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + builder.begin(Point::new(at.x + tx, at.y - ty)); + } + Event::Line { from: _, to } => { + builder.line_to(Point::new(to.x + tx, to.y - ty)); + } + Event::Quadratic { from: _, ctrl, to } => { + builder.quadratic_bezier_to( + Point::new(ctrl.x + tx, ctrl.y - ty), + Point::new(to.x + tx, to.y - ty), + ); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + builder.cubic_bezier_to( + Point::new(ctrl1.x + tx, ctrl1.y - ty), + Point::new(ctrl2.x + tx, ctrl2.y - ty), + Point::new(to.x + tx, to.y - ty), + ); + } + Event::End { + last: _, + first: _, + close, + } => { + builder.end(close); + } + } + } + builder.build() +} + +/// Extract glyph outlines as lyon Path objects in Y-up convention (for 3D). +fn extract_glyph_lyon_paths_yup( + layout: &Layout, + base_x: f32, + base_y: f32, + max_h: Option, +) -> Vec { + let mut paths = Vec::new(); + + for line_idx in 0..layout.len() { + let Some(line) = layout.get(line_idx) else { + continue; + }; + + if let Some(h) = max_h { + let metrics = line.metrics(); + if metrics.baseline + metrics.descent > h { + break; + } + } + + for item in line.items() { + let PositionedLayoutItem::GlyphRun(glyph_run) = item else { + continue; + }; + + let run = glyph_run.run(); + let font_data = run.font(); + let font_size = run.font_size(); + let normalized_coords = run.normalized_coords(); + + let Ok(font_ref) = FontRef::from_index(font_data.data.as_ref(), font_data.index) else { + continue; + }; + + let outlines = font_ref.outline_glyphs(); + let skrifa_size = Size::new(font_size); + let coords: Vec = normalized_coords + .iter() + .map(|&c| NormalizedCoord::from_bits(c)) + .collect(); + let location = LocationRef::new(&coords); + + for glyph in glyph_run.positioned_glyphs() { + let glyph_id = skrifa::GlyphId::new(glyph.id); + + if let Some(outline_glyph) = outlines.get(glyph_id) { + let mut pen = LyonOutlinePen::new(); + let settings = DrawSettings::unhinted(skrifa_size, location); + let _ = outline_glyph.draw(settings, &mut pen); + + if let Some(path) = pen.build() { + let tx = base_x + glyph.x; + let ty = base_y + glyph.y; + paths.push(translate_path_yup(&path, tx, ty)); + } + } + } + } + } + + paths +} + +/// Translate a lyon path by (tx, ty) and flip Y coordinates (font Y-up to screen Y-down). +fn translate_path_flip_y(path: &Path, tx: f32, ty: f32) -> Path { + let mut builder = Path::builder(); + for event in path.iter() { + use lyon::path::Event; + match event { + Event::Begin { at } => { + builder.begin(Point::new(at.x + tx, -at.y + ty)); + } + Event::Line { from: _, to } => { + builder.line_to(Point::new(to.x + tx, -to.y + ty)); + } + Event::Quadratic { from: _, ctrl, to } => { + builder.quadratic_bezier_to( + Point::new(ctrl.x + tx, -ctrl.y + ty), + Point::new(to.x + tx, -to.y + ty), + ); + } + Event::Cubic { + from: _, + ctrl1, + ctrl2, + to, + } => { + builder.cubic_bezier_to( + Point::new(ctrl1.x + tx, -ctrl1.y + ty), + Point::new(ctrl2.x + tx, -ctrl2.y + ty), + Point::new(to.x + tx, -to.y + ty), + ); + } + Event::End { + last: _, + first: _, + close, + } => { + builder.end(close); + } + } + } + builder.build() +} + +/// An `OutlinePen` that builds a lyon `Path` from a skrifa glyph outline. +struct LyonOutlinePen { + builder: lyon::path::path::Builder, + has_content: bool, +} + +impl LyonOutlinePen { + fn new() -> Self { + Self { + builder: Path::builder(), + has_content: false, + } + } + + fn build(self) -> Option { + if self.has_content { + Some(self.builder.build()) + } else { + None + } + } +} + +impl OutlinePen for LyonOutlinePen { + fn move_to(&mut self, x: f32, y: f32) { + self.builder.begin(Point::new(x, y)); + self.has_content = true; + } + + fn line_to(&mut self, x: f32, y: f32) { + self.builder.line_to(Point::new(x, y)); + } + + fn quad_to(&mut self, cx0: f32, cy0: f32, x: f32, y: f32) { + self.builder + .quadratic_bezier_to(Point::new(cx0, cy0), Point::new(x, y)); + } + + fn curve_to(&mut self, cx0: f32, cy0: f32, cx1: f32, cy1: f32, x: f32, y: f32) { + self.builder + .cubic_bezier_to(Point::new(cx0, cy0), Point::new(cx1, cy1), Point::new(x, y)); + } + + fn close(&mut self) { + self.builder.end(true); + } +} diff --git a/crates/processing_render/src/render/style.rs b/crates/processing_render/src/render/style.rs new file mode 100644 index 00000000..63495c58 --- /dev/null +++ b/crates/processing_render/src/render/style.rs @@ -0,0 +1,123 @@ +use std::ops::{Deref, DerefMut}; + +use bevy::{math::Affine2, prelude::*, render::render_resource::BlendState}; + +use super::command::{ShapeMode, TextAlignH, TextAlignV, TextStyle, TextWrapMode}; +use super::material::MaterialKey; +use super::primitive::StrokeConfig; + +#[derive(Debug, Clone, Copy)] +pub enum Fill { + None, + Color(Color), + /// Per-instance albedo buffer for [`Particles`](crate::particles::Particles) draws. + Buffer(Entity), +} + +impl Fill { + pub fn color(self) -> Option { + match self { + Fill::Color(color) => Some(color), + Fill::None | Fill::Buffer(_) => None, + } + } +} + +#[derive(Debug, Clone)] +pub struct Style { + pub fill: Fill, + pub stroke_color: Option, + pub stroke_weight: f32, + pub stroke_config: StrokeConfig, + pub material_key: MaterialKey, + pub blend_state: Option, + pub tint_color: Option, + pub image_mode: ShapeMode, + pub rect_mode: ShapeMode, + pub ellipse_mode: ShapeMode, + pub text_font_family: Option, + pub text_style: TextStyle, + pub text_weight: Option, + pub text_variations: Vec<([u8; 4], f32)>, + pub text_features: Vec<([u8; 4], u16)>, + pub text_size: f32, + pub text_align_h: TextAlignH, + pub text_align_v: TextAlignV, + pub text_leading: Option, + pub text_wrap: TextWrapMode, + pub text_glyph_colors: Option>, +} + +impl Default for Style { + fn default() -> Self { + Self { + fill: Fill::Color(Color::WHITE), + stroke_color: Some(Color::BLACK), + stroke_weight: 1.0, + stroke_config: StrokeConfig::default(), + material_key: MaterialKey::Color { + transparent: false, + background_image: None, + uv_transform: Affine2::IDENTITY, + blend_state: None, + }, + blend_state: None, + tint_color: None, + image_mode: ShapeMode::Corner, + rect_mode: ShapeMode::Corner, + ellipse_mode: ShapeMode::Center, + text_font_family: None, + text_style: TextStyle::Normal, + text_weight: None, + text_variations: Vec::new(), + text_features: Vec::new(), + text_size: 12.0, + text_align_h: TextAlignH::Left, + text_align_v: TextAlignV::Baseline, + text_leading: None, + text_wrap: TextWrapMode::Word, + text_glyph_colors: None, + } + } +} + +#[derive(Debug, Clone, Default)] +pub struct StyleStack { + current: Style, + stack: Vec