diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a379efed..a782172b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,9 @@ on: permissions: contents: read +env: + FFMPEG_VERSION: "7.1.1" + jobs: generate-stubs: runs-on: ubuntu-22.04 @@ -16,9 +19,18 @@ jobs: with: submodules: true - uses: ./.github/actions/setup + - name: Build FFmpeg + run: | + sudo apt-get install -y nasm pkg-config + curl -L https://ffmpeg.org/releases/ffmpeg-${{ env.FFMPEG_VERSION }}.tar.xz | tar xJ + cd ffmpeg-${{ env.FFMPEG_VERSION }} + ./configure --prefix=/usr/local --libdir=/usr/local/lib --enable-shared --disable-static --disable-programs --disable-doc + make -j$(nproc) + sudo make install + sudo ldconfig - name: Generate type stubs run: | - cargo build --release -p processing_pyo3 + cargo build --release -p processing_pyo3 --features video cargo run --release -p generate_stubs - uses: actions/upload-artifact@v4 with: @@ -31,7 +43,7 @@ jobs: strategy: matrix: # TODO: add aarch64 when we are ready to address long build times - target: [x86_64] + target: [x86_64] steps: - uses: actions/checkout@v4 with: @@ -52,7 +64,7 @@ jobs: container: "quay.io/pypa/manylinux_2_34_${{matrix.target}}:latest" docker-options: "-e PKG_CONFIG_PATH=/usr/lib64/pkgconfig:/usr/lib/${{matrix.target}}-linux-gnu/pkgconfig:/usr/local/lib/pkgconfig" target: ${{ matrix.target }} - args: --release --out dist --find-interpreter + args: --release --out dist --find-interpreter --features video manylinux: 2_34 before-script-linux: | EXTRA_PACKAGES="" @@ -60,11 +72,18 @@ jobs: EXTRA_PACKAGES="gcc-aarch64-linux-gnu" fi - dnf -y install cmake gcc-c++ \ + dnf -y install cmake gcc-c++ nasm \ pkgconfig alsa-lib-devel systemd-devel \ libX11-devel libXrandr-devel libXinerama-devel libXcursor-devel libXi-devel \ libxkbcommon-devel wayland-devel wayland-protocols-devel \ glfw-devel $EXTRA_PACKAGES + + curl -L https://ffmpeg.org/releases/ffmpeg-${{ env.FFMPEG_VERSION }}.tar.xz | tar xJ + cd ffmpeg-${{ env.FFMPEG_VERSION }} + ./configure --prefix=/usr/local --libdir=/usr/local/lib --enable-shared --disable-static --disable-programs --disable-doc + make -j$(nproc) + make install + ldconfig working-directory: crates/processing_pyo3 - uses: actions/upload-artifact@v4 with: @@ -92,12 +111,26 @@ jobs: with: name: type-stubs path: crates/processing_pyo3/mewnala + - name: Build FFmpeg (LGPL, shared) + run: | + brew install nasm pkg-config + curl -L https://ffmpeg.org/releases/ffmpeg-${{ env.FFMPEG_VERSION }}.tar.xz | tar xJ + cd ffmpeg-${{ env.FFMPEG_VERSION }} + ./configure --prefix=/usr/local --libdir=/usr/local/lib --enable-shared --disable-static --disable-programs --disable-doc + make -j$(sysctl -n hw.ncpu) + sudo make install - name: Build wheels uses: PyO3/maturin-action@v1 with: target: ${{ matrix.target }} - args: --release --out dist --find-interpreter + args: --release --out dist --find-interpreter --features video working-directory: crates/processing_pyo3 + - name: Bundle dylibs into wheels + run: | + pip install delocate + delocate-wheel -w dist-fixed crates/processing_pyo3/dist/*.whl + rm -rf crates/processing_pyo3/dist + mv dist-fixed crates/processing_pyo3/dist - uses: actions/upload-artifact@v4 with: name: wheels-macos-${{ matrix.target }} @@ -117,12 +150,29 @@ jobs: with: name: type-stubs path: crates/processing_pyo3/mewnala + - name: Install FFmpeg + shell: bash + run: | + curl -L -o ffmpeg.zip "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest/ffmpeg-n7.1-latest-win64-lgpl-shared-7.1.zip" + 7z x ffmpeg.zip -offmpeg-extract + mv ffmpeg-extract/ffmpeg-* ffmpeg + echo "FFMPEG_DIR=$(cygpath -w $(pwd)/ffmpeg)" >> $GITHUB_ENV - name: Build wheels uses: PyO3/maturin-action@v1 with: target: x64 - args: --release --out dist --find-interpreter + args: --release --out dist --find-interpreter --features video working-directory: crates/processing_pyo3 + - name: Bundle DLLs into wheels + shell: bash + run: | + pip install delvewheel + mkdir dist-fixed + for whl in crates/processing_pyo3/dist/*.whl; do + delvewheel repair --add-path "$FFMPEG_DIR/bin" -w dist-fixed "$whl" + done + rm -rf crates/processing_pyo3/dist + mv dist-fixed crates/processing_pyo3/dist - uses: actions/upload-artifact@v4 with: name: wheels-windows-x64 diff --git a/Cargo.lock b/Cargo.lock index c1bf6e52..42fe8b02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,22 +29,26 @@ dependencies = [ [[package]] name = "accesskit_consumer" -version = "0.35.0" +version = "0.38.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53cf47daed85312e763fbf85ceca136e0d7abc68e0a7e12abe11f48172bc3b10" +checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" dependencies = [ "accesskit", "hashbrown 0.16.1", ] [[package]] -name = "accesskit_consumer" -version = "0.38.0" +name = "accesskit_ios" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d10a236f96f87d70732e44520046785431ef01d5bcd6b041317bfadd2f88245" +checksum = "750c4e9f6ce888dfe8a10c0f1b5ceb646a7854fd46579d74919219d1bb314083" dependencies = [ "accesskit", + "accesskit_consumer", "hashbrown 0.16.1", + "objc2 0.5.2", + "objc2-foundation 0.2.2", + "objc2-ui-kit", ] [[package]] @@ -54,7 +58,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce02dc63b43f0c9296af9ac946312a2dc8814427d7a64d2d600971dac55b6076" dependencies = [ "accesskit", - "accesskit_consumer 0.38.0", + "accesskit_consumer", "hashbrown 0.16.1", "objc2 0.5.2", "objc2-app-kit 0.2.2", @@ -63,12 +67,12 @@ dependencies = [ [[package]] name = "accesskit_windows" -version = "0.32.1" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eff7009f1a532e917d66970a1e80c965140c6cfbbabbdde3d64e5431e6c78e21" +checksum = "106c2b961215864d1c2e703ee63269c25c4e80a577ffb2c1017b9c17dcdf83a1" dependencies = [ "accesskit", - "accesskit_consumer 0.35.0", + "accesskit_consumer", "hashbrown 0.16.1", "static_assertions", "windows 0.62.2", @@ -77,11 +81,12 @@ dependencies = [ [[package]] name = "accesskit_winit" -version = "0.32.2" +version = "0.33.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fe9a94394896352cc4660ca2288bd4ef883d83238853c038b44070c8f134313" +checksum = "d5b41e63a69f36d9f1f41e70464c7e5f72eee485ef26aab19f0b4f86e6c0a84c" dependencies = [ "accesskit", + "accesskit_ios", "accesskit_macos", "accesskit_windows", "raw-window-handle", @@ -182,15 +187,6 @@ dependencies = [ "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]] name = "android-properties" version = "0.2.2" @@ -212,6 +208,12 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + [[package]] name = "annotate-snippets" version = "0.12.16" @@ -294,6 +296,12 @@ version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d036a3c4ab069c7b410a2ce876bd74808d2d0888a82667669f8e783a898bf1" +[[package]] +name = "arbitrary-chunks" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ad8689a486416c401ea15715a4694de30054248ec627edbf31f49cb64ee4086" + [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -347,7 +355,7 @@ version = "0.38.0+1.3.281" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bb44936d800fea8f016d7f2311c6a4f97aebd5dc86f09906139ec848cf3a46f" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] @@ -467,43 +475,6 @@ 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.1" @@ -544,6 +515,39 @@ dependencies = [ "v_frame", ] +[[package]] +name = "avian3d" +version = "0.8.0-dev" +dependencies = [ + "approx", + "avian_derive", + "bevy", + "bevy_heavy", + "bevy_math", + "bevy_transform_interpolation", + "bitflags 2.13.1", + "derive_more", + "disqualified", + "glam_matrix_extras", + "itertools 0.15.0", + "obvhs", + "parry3d", + "parry3d-f64", + "smallvec", + "thiserror 2.0.20", + "thread_local", +] + +[[package]] +name = "avian_derive" +version = "0.2.3" +dependencies = [ + "proc-macro-error3", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "avif-serialize" version = "0.8.9" @@ -561,16 +565,14 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bevy" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_internal", ] [[package]] name = "bevy_a11y" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "accesskit", "bevy_app", @@ -581,16 +583,14 @@ dependencies = [ [[package]] name = "bevy_android" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "android-activity", ] [[package]] name = "bevy_animation" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_animation_macros", "bevy_app", @@ -621,18 +621,16 @@ dependencies = [ [[package]] name = "bevy_animation_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "quote", "syn 2.0.119", ] [[package]] name = "bevy_anti_alias" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -652,8 +650,7 @@ dependencies = [ [[package]] name = "bevy_app" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_derive", "bevy_ecs", @@ -673,8 +670,7 @@ dependencies = [ [[package]] name = "bevy_asset" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "async-broadcast", "async-channel", @@ -716,10 +712,9 @@ dependencies = [ [[package]] name = "bevy_asset_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "proc-macro2", "quote", "syn 2.0.119", @@ -727,8 +722,7 @@ dependencies = [ [[package]] name = "bevy_audio" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -742,8 +736,7 @@ dependencies = [ [[package]] name = "bevy_camera" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -762,13 +755,12 @@ dependencies = [ "serde", "smallvec", "thiserror 2.0.20", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_camera_controller" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_camera", @@ -784,8 +776,7 @@ dependencies = [ [[package]] name = "bevy_clipboard" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -798,8 +789,7 @@ dependencies = [ [[package]] name = "bevy_color" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_math", "bevy_reflect", @@ -808,13 +798,12 @@ dependencies = [ "encase", "serde", "thiserror 2.0.20", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_core_pipeline" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -848,28 +837,28 @@ dependencies = [ "bevy", "cudarc", "thiserror 2.0.20", - "wgpu", - "wgpu-hal", + "wgpu 29.0.4", + "wgpu-hal 29.0.4", "windows 0.58.0", ] [[package]] name = "bevy_derive" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "quote", "syn 2.0.119", ] [[package]] name = "bevy_dev_tools" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ + "bevy_animation", "bevy_app", "bevy_asset", + "bevy_audio", "bevy_camera", "bevy_color", "bevy_core_pipeline", @@ -877,13 +866,17 @@ dependencies = [ "bevy_ecs", "bevy_image", "bevy_input", + "bevy_light", "bevy_log", "bevy_math", + "bevy_mesh", "bevy_pbr", "bevy_picking", + "bevy_platform", "bevy_reflect", "bevy_render", "bevy_shader", + "bevy_sprite", "bevy_state", "bevy_text", "bevy_time", @@ -891,13 +884,13 @@ dependencies = [ "bevy_ui", "bevy_ui_render", "bevy_window", + "bevy_world_serialization", "tracing", ] [[package]] name = "bevy_diagnostic" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "atomic-waker", "bevy_app", @@ -913,8 +906,7 @@ dependencies = [ [[package]] name = "bevy_ecs" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "arrayvec", "bevy_ecs_macros", @@ -940,10 +932,9 @@ dependencies = [ [[package]] name = "bevy_ecs_macro_logic" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "proc-macro2", "quote", "syn 2.0.119", @@ -951,11 +942,10 @@ dependencies = [ [[package]] name = "bevy_ecs_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_ecs_macro_logic", - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "proc-macro2", "quote", "syn 2.0.119", @@ -963,17 +953,15 @@ dependencies = [ [[package]] name = "bevy_encase_derive" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "encase_derive_impl", ] [[package]] name = "bevy_feathers" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "accesskit", "bevy_a11y", @@ -1003,8 +991,7 @@ dependencies = [ [[package]] name = "bevy_gilrs" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -1018,8 +1005,7 @@ dependencies = [ [[package]] name = "bevy_gizmos" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1040,18 +1026,16 @@ dependencies = [ [[package]] name = "bevy_gizmos_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "quote", "syn 2.0.119", ] [[package]] name = "bevy_gizmos_render" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1078,8 +1062,7 @@ dependencies = [ [[package]] name = "bevy_gltf" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "async-lock", "base64", @@ -1101,20 +1084,28 @@ dependencies = [ "bevy_world_serialization", "fixedbitset", "gltf", - "itertools", + "itertools 0.14.0", "percent-encoding", "serde", "serde_json", "smallvec", "thiserror 2.0.20", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", +] + +[[package]] +name = "bevy_heavy" +version = "0.6.0-dev" +dependencies = [ + "bevy_math", + "bevy_reflect", + "glam_matrix_extras", ] [[package]] name = "bevy_image" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1136,13 +1127,12 @@ dependencies = [ "serde", "thiserror 2.0.20", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_input" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -1157,10 +1147,10 @@ dependencies = [ [[package]] name = "bevy_input_focus" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", + "bevy_camera", "bevy_ecs", "bevy_input", "bevy_math", @@ -1173,8 +1163,7 @@ dependencies = [ [[package]] name = "bevy_internal" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_a11y", "bevy_android", @@ -1232,8 +1221,7 @@ dependencies = [ [[package]] name = "bevy_light" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1252,13 +1240,12 @@ dependencies = [ "half", "smallvec", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_log" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "android_log-sys", "bevy_app", @@ -1274,31 +1261,17 @@ dependencies = [ [[package]] name = "bevy_macro_utils" -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" +version = "0.20.0-dev" dependencies = [ "proc-macro2", "quote", "syn 2.0.119", - "toml_edit 0.25.13+spec-1.1.0", + "toml_edit", ] [[package]] name = "bevy_material" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_asset", "bevy_derive", @@ -1314,30 +1287,28 @@ dependencies = [ "thiserror 2.0.20", "tracing", "variadics_please", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_material_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "quote", "syn 2.0.119", ] [[package]] name = "bevy_math" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "approx", "arrayvec", "bevy_reflect", "derive_more", - "glam 0.32.1", - "itertools", + "glam", + "itertools 0.14.0", "libm", "rand 0.10.2", "rand_distr", @@ -1348,8 +1319,7 @@ dependencies = [ [[package]] name = "bevy_mesh" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1365,12 +1335,13 @@ dependencies = [ "bytemuck", "derive_more", "encase", - "glam 0.32.1", + "glam", "half", "hexasphere", + "serde", "thiserror 2.0.20", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] @@ -1382,16 +1353,14 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" version = "0.2.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#3e59d3698281fcf6c56a6478be3ee12049737218" dependencies = [ "bevy", - "naga", + "naga 29.0.4", ] [[package]] name = "bevy_pbr" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "arrayvec", "bevy_app", @@ -1427,13 +1396,12 @@ dependencies = [ "static_assertions", "thiserror 2.0.20", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_picking" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1455,8 +1423,7 @@ dependencies = [ [[package]] name = "bevy_platform" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "critical-section", "foldhash 0.2.0", @@ -1476,8 +1443,7 @@ dependencies = [ [[package]] name = "bevy_post_process" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1500,13 +1466,11 @@ dependencies = [ [[package]] name = "bevy_ptr" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" [[package]] name = "bevy_reflect" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "assert_type_match", "bevy_platform", @@ -1518,7 +1482,7 @@ dependencies = [ "downcast-rs 2.0.2", "erased-serde", "foldhash 0.2.0", - "glam 0.32.1", + "glam", "indexmap", "inventory", "petgraph 0.8.3", @@ -1528,15 +1492,14 @@ dependencies = [ "thiserror 2.0.20", "uuid", "variadics_please", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_reflect_derive" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "indexmap", "proc-macro2", "quote", @@ -1546,8 +1509,7 @@ dependencies = [ [[package]] name = "bevy_render" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "async-channel", "bevy_app", @@ -1578,31 +1540,31 @@ dependencies = [ "derive_more", "downcast-rs 2.0.2", "encase", - "glam 0.32.1", + "glam", "image", "indexmap", - "itertools", + "itertools 0.14.0", "js-sys", - "naga", + "naga 30.0.0", "nonmax", "offset-allocator", "send_wrapper", "smallvec", "thiserror 2.0.20", + "tracing", "variadics_please", "wasm-bindgen", "weak-table", "web-sys", - "wgpu", - "wgpu-types", + "wgpu 30.0.0", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_render_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "proc-macro2", "quote", "syn 2.0.119", @@ -1610,8 +1572,7 @@ dependencies = [ [[package]] name = "bevy_scene" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1630,48 +1591,10 @@ dependencies = [ [[package]] name = "bevy_scene_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" 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 0.19.1", + "bevy_macro_utils", "proc-macro2", "quote", "syn 2.0.119", @@ -1679,27 +1602,25 @@ dependencies = [ [[package]] name = "bevy_shader" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_asset", "bevy_platform", "bevy_reflect", "bevy_utils", - "naga", + "naga 30.0.0", "naga_oil", "serde", "thiserror 2.0.20", "tracing", "wesl", - "wgpu-naga-bridge", - "wgpu-types", + "wgpu-naga-bridge 30.0.0", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_sprite" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1718,13 +1639,12 @@ dependencies = [ "bevy_window", "radsort", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_sprite_render" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1750,13 +1670,14 @@ dependencies = [ "derive_more", "fixedbitset", "nonmax", + "smallvec", + "static_assertions", "tracing", ] [[package]] name = "bevy_state" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -1770,18 +1691,16 @@ dependencies = [ [[package]] name = "bevy_state_macros" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ - "bevy_macro_utils 0.19.0", + "bevy_macro_utils", "quote", "syn 2.0.119", ] [[package]] name = "bevy_tasks" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "async-channel", "async-executor", @@ -1792,14 +1711,13 @@ dependencies = [ "crossbeam-queue", "derive_more", "futures-lite", - "heapless", + "heapless 0.9.3", "web-task", ] [[package]] name = "bevy_text" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1813,7 +1731,7 @@ dependencies = [ "bevy_platform", "bevy_reflect", "bevy_utils", - "parley 0.9.0", + "parley 0.11.0", "serde", "smallvec", "smol_str", @@ -1821,13 +1739,12 @@ dependencies = [ "sys-locale", "thiserror 2.0.20", "tracing", - "wgpu-types", + "wgpu-types 30.0.0", ] [[package]] name = "bevy_time" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -1840,8 +1757,7 @@ dependencies = [ [[package]] name = "bevy_transform" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_ecs", @@ -1854,10 +1770,16 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "bevy_transform_interpolation" +version = "0.6.0-dev" +dependencies = [ + "bevy", +] + [[package]] name = "bevy_ui" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "accesskit", "bevy_a11y", @@ -1882,7 +1804,7 @@ dependencies = [ "bevy_utils", "bevy_window", "derive_more", - "parley 0.9.0", + "parley 0.11.0", "smallvec", "swash", "taffy", @@ -1893,8 +1815,7 @@ dependencies = [ [[package]] name = "bevy_ui_render" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1925,8 +1846,7 @@ dependencies = [ [[package]] name = "bevy_ui_widgets" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "accesskit", "bevy_a11y", @@ -1940,16 +1860,16 @@ dependencies = [ "bevy_picking", "bevy_reflect", "bevy_text", + "bevy_time", "bevy_ui", "bevy_window", - "parley 0.9.0", + "parley 0.11.0", "smol_str", ] [[package]] name = "bevy_utils" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "async-channel", "bevy_platform", @@ -1960,8 +1880,7 @@ dependencies = [ [[package]] name = "bevy_window" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -1978,8 +1897,7 @@ dependencies = [ [[package]] name = "bevy_winit" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "accesskit", "accesskit_winit", @@ -2004,14 +1922,13 @@ dependencies = [ "tracing", "wasm-bindgen", "web-sys", - "wgpu-types", + "wgpu-types 30.0.0", "winit", ] [[package]] name = "bevy_world_serialization" -version = "0.19.0" -source = "git+https://github.com/processing/bevy?branch=main#9da7c6250114ee8613ae54ab3ad7bb7e43a2f91b" +version = "0.20.0-dev" dependencies = [ "bevy_app", "bevy_asset", @@ -2046,12 +1963,30 @@ dependencies = [ "proc-macro2", "quote", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "shlex 1.3.0", "syn 2.0.119", "which", ] +[[package]] +name = "bindgen" +version = "0.72.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" +dependencies = [ + "bitflags 2.13.1", + "cexpr", + "clang-sys", + "itertools 0.13.0", + "proc-macro2", + "quote", + "regex", + "rustc-hash 2.1.3", + "shlex 1.3.0", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.8.0" @@ -2070,6 +2005,15 @@ dependencies = [ "bit-vec 0.9.1", ] +[[package]] +name = "bit-set" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2f926cc3060f09db9ebc5b52823d85268d24bb917e472c0c4bea35780a7d" +dependencies = [ + "bit-vec 0.9.1", +] + [[package]] name = "bit-vec" version = "0.8.0" @@ -2142,6 +2086,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-pseudorand" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2097358495d244a0643746f4d13eedba4608137008cf9dec54e53a3b700115a6" +dependencies = [ + "chiapos-chacha8", + "nanorand 0.6.1", +] + [[package]] name = "block2" version = "0.5.1" @@ -2249,6 +2203,12 @@ dependencies = [ "wayland-client", ] +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cbindgen" version = "0.29.4" @@ -2324,6 +2284,42 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "chiapos-chacha8" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33f8be573a85f6c2bc1b8e43834c07e32f95e489b914bf856c0549c3c269cd0a" +dependencies = [ + "rayon", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + [[package]] name = "clang-sys" version = "1.9.1" @@ -2332,7 +2328,7 @@ checksum = "157a8ba7b480713b56f4c09fd13fc3e0a22a5dfab8097ba61cbc5feef950788a" dependencies = [ "glob", "libc", - "libloading 0.8.9", + "libloading", ] [[package]] @@ -2362,15 +2358,6 @@ 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" @@ -2645,27 +2632,6 @@ dependencies = [ "objc2-core-foundation", ] -[[package]] -name = "coremidi" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a57ede822fdaf19280cf1320a5a5d3a522c75c910d01750af1e8122b6ad2595b" -dependencies = [ - "block2 0.6.2", - "core-foundation 0.10.1", - "core-foundation-sys 0.8.7", - "coremidi-sys", -] - -[[package]] -name = "coremidi-sys" -version = "3.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a2b8c2cefa9a8f712213c5a1383ffe428efc8f1a1fd1e2f757be94daf7e256a" -dependencies = [ - "core-foundation-sys 0.8.7", -] - [[package]] name = "cpal" version = "0.17.3" @@ -2678,38 +2644,7 @@ dependencies = [ "jni 0.21.1", "js-sys", "libc", - "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", + "mach2", "ndk", "ndk-context", "num-derive", @@ -2725,7 +2660,6 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "windows 0.62.2", - "windows-core 0.62.2", ] [[package]] @@ -2756,14 +2690,50 @@ dependencies = [ ] [[package]] -name = "critical-section" -version = "1.2.0" +name = "criterion" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" +checksum = "f2b12d017a929603d80db1831cd3a24082f8137ce19c69e6447f54f5fc8d692f" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "is-terminal", + "itertools 0.10.5", + "num-traits", + "once_cell", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_derive", + "serde_json", + "tinytemplate", + "walkdir", +] [[package]] -name = "crossbeam-channel" -version = "0.5.16" +name = "criterion-plot" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b50826342786a51a89e2da3a28f1c32b06e387201bc2d19791f622c673706b1" +dependencies = [ + "cast", + "itertools 0.10.5", +] + +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ @@ -2837,7 +2807,7 @@ version = "0.18.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3aa12038120eb13347a6ae2ffab1d34efe78150125108627fd85044dd4d6ff1e" dependencies = [ - "libloading 0.8.9", + "libloading", ] [[package]] @@ -2932,17 +2902,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "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", + "libloading", ] [[package]] @@ -3117,12 +3077,6 @@ dependencies = [ "zune-inflate", ] -[[package]] -name = "extended" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9673d8203fcb076b19dfd17e38b3d4ae9f44959416ea532ce72415a6020365" - [[package]] name = "fastrand" version = "2.5.0" @@ -3145,179 +3099,44 @@ dependencies = [ ] [[package]] -name = "file-id" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" -dependencies = [ - "windows-sys 0.60.2", -] - -[[package]] -name = "find-msvc-tools" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" - -[[package]] -name = "firewheel" -version = "0.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cce6a0e2684404563f22daf00caa3672a9fd1503af5c58cd79a544a8221cec6e" -dependencies = [ - "firewheel-core", - "firewheel-cpal", - "firewheel-graph", - "firewheel-nodes", - "firewheel-rtaudio", - "firewheel-symphonium", - "thiserror 2.0.20", -] - -[[package]] -name = "firewheel-core" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c793ba83a0c4861d467af68844970bd063249189790f00c7feda5019732466b" -dependencies = [ - "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 = "firewheel-cpal" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cf893a68d8b89fbb6eeca6deb5166fd342cc827bf4ad37d31a973bc861bf038" -dependencies = [ - "audioadapter-buffers", - "bevy_platform", - "cpal 0.18.1", - "firewheel-core", - "firewheel-graph", - "fixed-resample", - "thiserror 2.0.20", - "tracing", -] - -[[package]] -name = "firewheel-graph" -version = "0.12.1" +name = "ffmpeg-next" +version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00257afdba7edc48ce1667fc144d453f7bd17674a78450207507ca286e818b82" +checksum = "f7c4bd5ab1ac61f29c634df1175d350ded29cf74c3c6d4f7030431a5ae3c7d5d" dependencies = [ - "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 = "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 = "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", + "ffmpeg-sys-next", + "libc", ] [[package]] -name = "firewheel-rtaudio" -version = "0.12.0" +name = "ffmpeg-sys-next" +version = "8.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a136e3893d1c1b691f355ba7b535e0a7df16ad49b7788aa88805bf473105a755" +checksum = "a314bc0e022a33a99567ed4bd2576bd58ffd8fcff7891c29194cfecc26a62547" dependencies = [ - "audioadapter-buffers", - "bevy_platform", - "firewheel-core", - "firewheel-graph", - "rtaudio", - "thiserror 2.0.20", - "tracing", + "bindgen 0.72.1", + "cc", + "libc", + "num_cpus", + "pkg-config", + "vcpkg", ] [[package]] -name = "firewheel-symphonium" -version = "0.12.0" +name = "file-id" +version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8261282ec848bcd8e64c715fb6d3778e2208819b1c5c015f04bc8709ac5465f" +checksum = "e1fc6a637b6dc58414714eddd9170ff187ecb0933d4c7024d1abbd23a3cc26e9" dependencies = [ - "bevy_platform", - "firewheel-core", - "symphonium", + "windows-sys 0.60.2", ] [[package]] -name = "fixed-resample" -version = "0.12.1" +name = "find-msvc-tools" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3505607cc0adc388ce98f59707477c02fbd05a2fa0c1b8c4cf42b71a3cb9c34" -dependencies = [ - "audioadapter", - "audioadapter-buffers", - "ringbuf", - "rubato 4.0.0", -] +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "fixedbitset" @@ -3349,7 +3168,7 @@ checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "nanorand", + "nanorand 0.7.0", "spin 0.9.9", ] @@ -3380,15 +3199,6 @@ dependencies = [ "bytemuck", ] -[[package]] -name = "font-types" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b38ad915f6dadd993ced50848a8291a543bd41ca62bc10740d5e64e2ab4cfd7" -dependencies = [ - "bytemuck", -] - [[package]] name = "font-types" version = "0.12.3" @@ -3423,15 +3233,15 @@ dependencies = [ [[package]] name = "fontique" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c20b425addb8661e97fe1d51c4d8bcec3ec29ed6ad0db983976a7521276b8f7" +checksum = "1e04c4750a17111ebd77c3e0aea00476ce33f59235bc4d9e7f0aded5033ad3fc" dependencies = [ "hashbrown 0.17.1", "linebender_resource_handle", "memmap2", "parlance", - "read-fonts 0.39.2", + "read-fonts 0.40.2", "smallvec", ] @@ -3477,6 +3287,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fsevent-sys" version = "4.1.0" @@ -3681,28 +3500,11 @@ dependencies = [ [[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" +version = "0.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +checksum = "7360bd2cd76e0cd9032d42cf2922155cecea2685b0cfa4630c3246df030bcfd6" dependencies = [ + "approx", "bytemuck", "encase", "libm", @@ -3710,6 +3512,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "glam_matrix_extras" +version = "0.4.0-dev" +dependencies = [ + "bevy_reflect", + "glam", +] + +[[package]] +name = "glamx" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9847f980afb0628849bcd6a406cdffd29eb9d53fca83e1646005901a9acd4c5c" +dependencies = [ + "approx", + "glam", + "num-traits", + "simba", +] + [[package]] name = "glfw" version = "0.60.0" @@ -3740,6 +3562,16 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4eba85ea1d0a966a983acd07deee566e67395d2d96b6fb39e62b5a833f1eb0b" +[[package]] +name = "gloo-events" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27c26fb45f7c385ba980f5fa87ac677e363949e065a083722697ef1b2cc91e41" +dependencies = [ + "wasm-bindgen", + "web-sys", +] + [[package]] name = "glow" version = "0.17.0" @@ -3850,12 +3682,11 @@ checksum = "b40ca9252762c466af32d0b1002e91e4e1bc5398f77455e55474deb466355ff5" [[package]] name = "guillotiere" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b62d5865c036cb1393e23c50693df631d3f5d7bcca4c04fe4cc0fd592e74a782" +checksum = "6b17e70c989c36bad147b27a58d148c0741c51448aa5653436547323e524d0ab" dependencies = [ "euclid", - "svg_fmt", ] [[package]] @@ -3887,14 +3718,13 @@ dependencies = [ [[package]] name = "harfrust" -version = "0.6.2" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "551ed25397e4b444e89686602877d5cf3a7f6e3d548dcac37a8357d1e195f4df" +checksum = "f0589ddd0d2935dd2845827ac606b4081c266225d613b268ed2910f832889cab" dependencies = [ "bitflags 2.13.1", "bytemuck", - "core_maths", - "read-fonts 0.39.2", + "read-fonts 0.40.2", "smallvec", ] @@ -3938,6 +3768,16 @@ dependencies = [ "foldhash 0.2.0", ] +[[package]] +name = "heapless" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad" +dependencies = [ + "hash32", + "stable_deref_trait", +] + [[package]] name = "heapless" version = "0.9.3" @@ -3963,12 +3803,12 @@ checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hexasphere" -version = "18.0.0" +version = "19.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "177ea6330876de4ef08e184f827b98acc037614a4ce409902c5b0d53a9c2e951" +checksum = "163385eff5a0cf606ebe2c10b3a44ab02f4306e83fbd068a64f5e1d72a3049ac" dependencies = [ "constgebra", - "glam 0.32.1", + "glam", "tinyvec", ] @@ -3987,17 +3827,6 @@ 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.3.0" @@ -4126,6 +3955,27 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ae293c039020f9ec10710af98d29ce6aa2051486638b49c9a6409f3b4a9e98ad" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" version = "0.25.10" @@ -4224,6 +4074,17 @@ dependencies = [ "rustversion", ] +[[package]] +name = "is-terminal" +version = "0.4.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" +dependencies = [ + "hermit-abi", + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -4232,28 +4093,46 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.10.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" dependencies = [ "either", ] [[package]] -name = "itoa" -version = "1.0.18" +name = "itertools" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] [[package]] -name = "java-locator" -version = "0.1.9" +name = "itertools" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09c46c1fe465c59b1474e665e85e1256c3893dd00927b8d55f63b09044c1e64f" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" dependencies = [ - "glob", + "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.21.1" @@ -4263,9 +4142,7 @@ 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", @@ -4303,23 +4180,8 @@ dependencies = [ ] [[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]] -name = "jni-sys" -version = "0.3.1" +name = "jni-sys" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" dependencies = [ @@ -4382,7 +4244,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" dependencies = [ "libc", - "libloading 0.8.9", + "libloading", "pkg-config", ] @@ -4430,7 +4292,7 @@ dependencies = [ "ascii-canvas", "bit-set 0.8.0", "ena", - "itertools", + "itertools 0.14.0", "lalrpop-util", "petgraph 0.7.1", "regex", @@ -4563,16 +4425,6 @@ dependencies = [ "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" @@ -4758,12 +4610,6 @@ dependencies = [ "libc", ] -[[package]] -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" @@ -4822,27 +4668,6 @@ dependencies = [ "objc", ] -[[package]] -name = "midir" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77c12e74a8604bc07f9a3fcf3d5889a81bc96ca6e07d6a114d63fc0371f3e5a4" -dependencies = [ - "alsa", - "cc", - "coremidi", - "jni 0.21.1", - "jni-min-helper", - "js-sys", - "libc", - "log", - "ndk-context", - "parking_lot", - "wasm-bindgen", - "web-sys", - "windows 0.62.2", -] - [[package]] name = "minimal-lexical" version = "0.2.1" @@ -4906,6 +4731,12 @@ dependencies = [ "nasm-rs", ] +[[package]] +name = "mutants" +version = "0.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc0287524726960e07b119cebd01678f852f147742ae0d925e6a520dca956126" + [[package]] name = "naga" version = "29.0.4" @@ -4926,45 +4757,92 @@ dependencies = [ "log", "num-traits", "once_cell", - "pp-rs", - "rustc-hash", + "rustc-hash 1.1.0", "serde", "spirv", "thiserror 2.0.20", "unicode-ident", ] +[[package]] +name = "naga" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23bf0a141a9ab6f07dbb492db53245e464bc9db42f407772d9ae03d83a2c1033" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bitflags 2.13.1", + "cfg-if 1.0.4", + "cfg_aliases", + "codespan-reporting 0.13.1", + "half", + "hashbrown 0.17.1", + "indexmap", + "libm", + "log", + "naga-types", + "num-traits", + "once_cell", + "pp-rs", + "rustc-hash 1.1.0", + "spirv", + "thiserror 2.0.20", + "unicode-ident", +] + +[[package]] +name = "naga-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "658200ddc25c6c7b860747516d132d1b284c0fafb7a380233acee9a72fb30e11" +dependencies = [ + "hashbrown 0.17.1", + "indexmap", + "rustc-hash 1.1.0", + "serde", + "thiserror 2.0.20", +] + [[package]] name = "naga_oil" -version = "0.22.0" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "319f03062940ed85c03da020042618356bb8ca5263020322930883b05e30208e" +checksum = "c028f8a00a344c5e80c15c4d4f0056c59767c03bad3ad8422f8816e198d9bce7" dependencies = [ "codespan-reporting 0.12.0", "data-encoding", "indexmap", - "naga", + "naga 30.0.0", "regex", - "rustc-hash", + "rustc-hash 1.1.0", "thiserror 2.0.20", "tracing", "unicode-ident", ] [[package]] -name = "nannou_midi" +name = "nannou_video" version = "0.1.0" -source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f713c439c03b4e4614c2ef8ff899932efd0a1c44" +source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f7f7f10bae536d4318c7a027aaa612886b8ac286" dependencies = [ "bevy", - "crossbeam-channel", - "midir", + "flume", + "gloo-events", + "js-sys", + "serde", + "tempfile", + "thiserror 2.0.20", + "video-rs", + "wasm-bindgen", + "web-sys", + "wgpu-types 28.0.0", ] [[package]] name = "nannou_webcam" version = "0.1.0" -source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f713c439c03b4e4614c2ef8ff899932efd0a1c44" +source = "git+https://github.com/nannou-org/nannou?branch=bevy-refactor#f7f7f10bae536d4318c7a027aaa612886b8ac286" dependencies = [ "bevy", "console_error_panic_hook", @@ -4975,6 +4853,12 @@ dependencies = [ "web-sys", ] +[[package]] +name = "nanorand" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "729eb334247daa1803e0a094d0a5c55711b85571179f5ec6e53eccfdf7008958" + [[package]] name = "nanorand" version = "0.7.0" @@ -5274,6 +5158,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "num_enum" version = "0.7.6" @@ -5383,13 +5277,35 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-av-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "478ae33fcac9df0a18db8302387c666b8ef08a3e2d62b510ca4fc278a384b6c0" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-avf-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-core-image 0.3.2", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-image-io", + "objc2-media-toolbox", + "objc2-quartz-core 0.3.2", +] + [[package]] name = "objc2-avf-audio" 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", ] @@ -5535,6 +5451,22 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-core-media" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05ec576860167a15dd9fce7fbee7512beb4e31f532159d3482d1f9c6caedf31d" +dependencies = [ + "bitflags 2.13.1", + "block2 0.6.2", + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-video", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -5554,10 +5486,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6" dependencies = [ "bitflags 2.13.1", + "block2 0.6.2", "objc2 0.6.4", "objc2-core-foundation", "objc2-core-graphics", "objc2-io-surface", + "objc2-metal 0.3.2", ] [[package]] @@ -5592,6 +5526,17 @@ dependencies = [ "objc2-core-foundation", ] +[[package]] +name = "objc2-image-io" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b0446e98cf4a784cc7a0177715ff317eeaa8463841c616cfc78aa4f953c4ea" +dependencies = [ + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-core-graphics", +] + [[package]] name = "objc2-io-kit" version = "0.3.2" @@ -5610,8 +5555,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" dependencies = [ "bitflags 2.13.1", + "libc", "objc2 0.6.4", "objc2-core-foundation", + "objc2-foundation 0.3.2", ] [[package]] @@ -5626,6 +5573,18 @@ dependencies = [ "objc2-foundation 0.2.2", ] +[[package]] +name = "objc2-media-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edd9fdde720df3da7046bb9097811000c1e7ab5cd579fa89d96b27d56781fb30" +dependencies = [ + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-core-media", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -5646,8 +5605,11 @@ checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ "bitflags 2.13.1", "block2 0.6.2", + "dispatch2", "objc2 0.6.4", + "objc2-core-foundation", "objc2-foundation 0.3.2", + "objc2-io-surface", ] [[package]] @@ -5672,6 +5634,7 @@ dependencies = [ "bitflags 2.13.1", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", ] @@ -5740,6 +5703,20 @@ dependencies = [ "cc", ] +[[package]] +name = "obvhs" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf590a9310ba69b2340562d39f0c9bddd3677130bfd24a5df22941552e791bac" +dependencies = [ + "bytemuck", + "glam", + "half", + "log", + "rdst", + "static_assertions", +] + [[package]] name = "offset-allocator" version = "0.2.0" @@ -5771,6 +5748,12 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "orbclient" version = "0.3.55" @@ -5850,12 +5833,12 @@ dependencies = [ [[package]] name = "parley" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fad031076f48f0d4d85ce1aea9b94b4e715a4d636a030a123038f8f5b5e4343" +checksum = "e0478b47dd9885a5e0a4f1c0782ffc42bf6ee8c41dea0917d7a9bcee3e6585fc" dependencies = [ - "fontique 0.9.0", - "harfrust 0.6.2", + "fontique 0.11.0", + "harfrust 0.10.0", "hashbrown 0.17.1", "icu_normalizer", "icu_properties", @@ -5863,18 +5846,81 @@ dependencies = [ "linebender_resource_handle", "parlance", "parley_data", - "skrifa 0.42.1", + "skrifa 0.43.2", ] [[package]] name = "parley_data" -version = "0.9.0" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19ab9ace3fad1b9ed603ddac5b595e69931fc50263d7e04e4055015b77b02da5" +checksum = "a649e01a1acc917247ee147b56b8a1fa91824acf7117bd003b4204306d601255" dependencies = [ "icu_properties", ] +[[package]] +name = "parry3d" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15fa77de549af010eddaf4bcb365b39aee96f93f65489b9a49b2de616fc664e8" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.13.1", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash 0.2.0", + "glamx", + "hashbrown 0.17.1", + "log", + "num-derive", + "num-traits", + "ordered-float", + "rayon", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "static_assertions", + "thiserror 2.0.20", +] + +[[package]] +name = "parry3d-f64" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd4914d128fa7236407b1d12fcdc689f7dc7010faae7ab4272c509c8b937c1cb" +dependencies = [ + "approx", + "arrayvec", + "bitflags 2.13.1", + "downcast-rs 2.0.2", + "either", + "ena", + "foldhash 0.2.0", + "glamx", + "hashbrown 0.17.1", + "log", + "num-derive", + "num-traits", + "ordered-float", + "rayon", + "rstar", + "simba", + "slab", + "smallvec", + "spade", + "thiserror 2.0.20", +] + +[[package]] +name = "partition" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "947f833aaa585cf12b8ec7c0476c98784c49f33b861376ffc84ed92adebf2aba" + [[package]] name = "paste" version = "1.0.15" @@ -5980,6 +6026,34 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "png" version = "0.18.1" @@ -6074,40 +6148,43 @@ dependencies = [ ] [[package]] -name = "primal-check" -version = "0.3.4" +name = "proc-macro-crate" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "num-integer", + "toml_edit", ] [[package]] -name = "proc-macro-crate" -version = "3.5.0" +name = "proc-macro-error-attr3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" dependencies = [ - "toml_edit 0.25.13+spec-1.1.0", + "proc-macro2", + "quote", ] [[package]] -name = "proc-macro2" -version = "1.0.107" +name = "proc-macro-error3" +version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" dependencies = [ - "unicode-ident", + "proc-macro-error-attr3", + "proc-macro2", + "quote", + "syn 3.0.3", ] [[package]] -name = "process_path" -version = "0.1.4" +name = "proc-macro2" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f676f11eb0b3e2ea0fbaee218fa6b806689e2297b8c8adc5bf73df465c4f6171" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "libc", - "winapi", + "unicode-ident", ] [[package]] @@ -6116,13 +6193,13 @@ version = "0.0.8" dependencies = [ "bevy", "js-sys", - "processing_audio", "processing_core", "processing_cuda", "processing_glfw", "processing_input", - "processing_midi", + "processing_physics", "processing_render", + "processing_video", "processing_webcam", "rand 0.10.2", "tracing", @@ -6131,14 +6208,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "processing_audio" -version = "0.1.0" -dependencies = [ - "bevy", - "bevy_seedling", -] - [[package]] name = "processing_core" version = "0.0.5" @@ -6167,6 +6236,8 @@ dependencies = [ "cbindgen", "half", "processing", + "processing_physics", + "processing_video", ] [[package]] @@ -6190,12 +6261,14 @@ dependencies = [ ] [[package]] -name = "processing_midi" -version = "0.0.5" +name = "processing_physics" +version = "0.0.1" dependencies = [ + "avian3d", "bevy", - "nannou_midi", "processing_core", + "processing_render", + "thiserror 2.0.20", ] [[package]] @@ -6208,6 +6281,7 @@ dependencies = [ "processing_cuda", "processing_glfw", "processing_render", + "processing_video", "processing_webcam", "pyo3", "rand 0.10.2", @@ -6225,10 +6299,12 @@ dependencies = [ "half", "js-sys", "lyon", - "naga", + "naga 29.0.4", "notosans", "objc2 0.6.4", "objc2-app-kit 0.3.2", + "objc2-io-surface", + "objc2-metal 0.3.2", "parley 0.7.0", "processing_core", "raw-window-handle", @@ -6237,10 +6313,31 @@ dependencies = [ "wasm-bindgen-futures", "web-sys", "wesl", - "wgpu", + "wgpu 30.0.0", "windows 0.58.0", ] +[[package]] +name = "processing_video" +version = "0.0.1" +dependencies = [ + "bevy", + "block2 0.6.2", + "nannou_video", + "objc2 0.6.4", + "objc2-av-foundation", + "objc2-core-foundation", + "objc2-core-media", + "objc2-core-video", + "objc2-foundation 0.3.2", + "objc2-io-surface", + "processing_core", + "processing_render", + "tempfile", + "thiserror 2.0.20", + "video-rs", +] + [[package]] name = "processing_wasm" version = "0.0.5" @@ -6508,7 +6605,7 @@ dependencies = [ "built", "cfg-if 1.0.4", "interpolate_name", - "itertools", + "itertools 0.14.0", "libc", "libfuzzer-sys", "log", @@ -6590,46 +6687,41 @@ dependencies = [ ] [[package]] -name = "read-fonts" -version = "0.35.0" +name = "rdst" +version = "0.20.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" +checksum = "6e7970b4e577b76a96d5e56b5f6662b66d1a4e1f5bb026ee118fc31b373c2752" dependencies = [ - "bytemuck", - "core_maths", - "font-types 0.10.1", + "arbitrary-chunks", + "block-pseudorand", + "criterion", + "partition", + "tikv-jemallocator", + "voracious_radix_sort", ] [[package]] name = "read-fonts" -version = "0.39.2" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4ed38b89c2c77ff968c524145ad65fb010f38af5c7a224b53b81d47ac2daa81" +checksum = "6717cf23b488adf64b9d711329542ba34de147df262370221940dfabc2c91358" dependencies = [ "bytemuck", - "font-types 0.11.3", + "core_maths", + "font-types 0.10.1", ] [[package]] name = "read-fonts" -version = "0.41.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046a7d674daf459825b32f5062056d6882db0d2f5a479fbd76ccfc870ac18709" +checksum = "487889119a5f19ff7c0a20637196bdc76b9f54ebec17e3588b5d75e4999f8773" 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" @@ -6692,12 +6784,6 @@ dependencies = [ "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.11" @@ -6720,15 +6806,10 @@ dependencies = [ ] [[package]] -name = "ringbuf" -version = "0.5.1" +name = "robust" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a158e09ede21a14b172ca6cdd6208386c6ae2cb6acef58d774368ef8c450dfa7" -dependencies = [ - "crossbeam-utils", - "portable-atomic", - "portable-atomic-util", -] +checksum = "4e27ee8bb91ca0adcf0ecb116293afa12d393f9c2b9b9cd54d33e8078fe19839" [[package]] name = "rodio" @@ -6736,7 +6817,7 @@ version = "0.22.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a536bb79db59098ef71a4dd4246c02eb87b316deceb1b68e0cde7167ec01eb" dependencies = [ - "cpal 0.17.3", + "cpal", "dasp_sample", "lewton", "num-rational", @@ -6768,60 +6849,27 @@ dependencies = [ ] [[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" +name = "rstar" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6dd52e80cfc21894deadf554a5673002938ae4625f7a283e536f9cf7c17b0d5" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" dependencies = [ - "num-complex", - "num-integer", + "heapless 0.8.0", "num-traits", - "realfft", + "smallvec", ] [[package]] -name = "rubato" -version = "4.0.0" +name = "rustc-hash" +version = "1.1.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", -] +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" [[package]] name = "rustc-hash" -version = "1.1.0" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -6832,20 +6880,6 @@ 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" @@ -6856,7 +6890,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.59.0", + "windows-sys 0.52.0", ] [[package]] @@ -6887,6 +6921,15 @@ dependencies = [ "twox-hash", ] +[[package]] +name = "safe_arch" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42c6efa15875e6ecb39ca61fb0b0c1a40b84fac5a5ffe71eef7d1000c8eb3f5f" +dependencies = [ + "bytemuck", +] + [[package]] name = "same-file" version = "1.0.6" @@ -7036,6 +7079,18 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "simba" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1a7200d82ff1c7b4235efd12f11e2537a402c91cf83a5cb97ce80eef787fde7" +dependencies = [ + "approx", + "num-complex", + "num-traits", + "wide", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -7085,22 +7140,12 @@ dependencies = [ [[package]] name = "skrifa" -version = "0.42.1" +version = "0.43.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c34617370ae968efb7161bb2beb517d9084659aae19e24b89e3db25b46e4564" +checksum = "4cbe997d0f2480442d727488fbe2150779114cbe480ecdbadef58b33e0318ffb" dependencies = [ "bytemuck", - "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", + "read-fonts 0.40.2", ] [[package]] @@ -7158,6 +7203,18 @@ dependencies = [ "serde", ] +[[package]] +name = "spade" +version = "2.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9699399fd9349b00b184f5635b074f9ec93afffef30c853f8c875b32c0f8c7fa" +dependencies = [ + "hashbrown 0.16.1", + "num-traits", + "robust", + "smallvec", +] + [[package]] name = "spin" version = "0.9.9" @@ -7206,12 +7263,6 @@ 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" @@ -7236,131 +7287,15 @@ 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 = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" -dependencies = [ - "skrifa 0.44.0", - "yazi", - "zeno", -] - -[[package]] -name = "symphonia" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7edef6a96b696d4e0cab5ee9ebb7ca155ed95f30a6b45bbb8b97d2727f02424" -dependencies = [ - "lazy_static", - "symphonia-codec-pcm", - "symphonia-codec-vorbis", - "symphonia-core", - "symphonia-format-ogg", - "symphonia-format-riff", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-codec-pcm" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e04ba75686acbe43542fdd374571195f0530c0b7785ca25cc6840e9c6c4b6eea" -dependencies = [ - "log", - "symphonia-core", -] - -[[package]] -name = "symphonia-codec-vorbis" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73d90b4fcf796137cc683c538282804ff9629f8ad9dbfd881fcbba331ac4e986" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core", -] - -[[package]] -name = "symphonia-common" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acc3fcc18ec9b8cdd48614e259c4cf0d27b71d41e5d9b120b42c5adab12d7c4" -dependencies = [ - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-core" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01c412864d599d4750d0c3d684d7e093ec05e5309681ef5252cc1096a437f6e0" -dependencies = [ - "bitflags 2.13.1", - "bytemuck", - "lazy_static", - "log", - "num-complex", - "rustfft", - "smallvec", -] - -[[package]] -name = "symphonia-format-ogg" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b5495e7f7e3c7035328d82b6d6e377eef289bb0c4105bdeb557fc93a833f994" -dependencies = [ - "log", - "symphonia-common", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-format-riff" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ff70929083a8c1a5f6cd7c904b6071c7914ad04739b510c2f7239dfc9b7dabe" -dependencies = [ - "extended", - "log", - "symphonia-core", - "symphonia-metadata", -] - -[[package]] -name = "symphonia-metadata" -version = "0.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83713a97705d77bdef7cdbc0768fd6e5a54e4cd7e48d60a806ae85639e2c87c6" -dependencies = [ - "lazy_static", - "log", - "regex-lite", - "smallvec", - "symphonia-core", -] - -[[package]] -name = "symphonium" -version = "0.12.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cc514081a8a6eb44a4ee5e68d981bf11d976876d0612082121969b31e7d53a8" +checksum = "6c2499c2d826531388872b2268718aed907a39bd785ab0dcfe57fab26283f92e" dependencies = [ - "fixed-resample", - "symphonia", - "tracing", + "skrifa 0.43.2", + "yazi", + "zeno", ] [[package]] @@ -7407,9 +7342,9 @@ dependencies = [ [[package]] name = "sysinfo" -version = "0.38.4" +version = "0.39.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92ab6a2f8bfe508deb3c6406578252e491d299cbbf3bc0529ecc3313aee4a52f" +checksum = "d2071df9448915b71c4fe6d25deaf1c22f12bd234f01540b77312bb8e41361e6" dependencies = [ "libc", "memchr", @@ -7517,12 +7452,6 @@ 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" @@ -7537,6 +7466,26 @@ dependencies = [ "zune-jpeg", ] +[[package]] +name = "tikv-jemalloc-sys" +version = "0.5.4+5.3.0-patched" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9402443cb8fd499b6f327e40565234ff34dbda27460c5b47db0db77443dd85d1" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "tikv-jemallocator" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "965fe0c26be5c56c94e38ba547249074803efd52adfb66de62107d95aab3eaca" +dependencies = [ + "libc", + "tikv-jemalloc-sys", +] + [[package]] name = "tiny-skia" version = "0.11.4" @@ -7573,6 +7522,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -7621,18 +7580,6 @@ dependencies = [ "serde_core", ] -[[package]] -name = "toml_edit" -version = "0.23.10+spec-1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" -dependencies = [ - "indexmap", - "toml_datetime 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" @@ -7744,25 +7691,6 @@ 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" @@ -7817,6 +7745,29 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsynn" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "501a7adf1a4bd9951501e5c66621e972ef8874d787628b7f90e64f936ef7ec0a" +dependencies = [ + "mutants", + "proc-macro2", + "rustc-hash 2.1.3", +] + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + [[package]] name = "utf8_iter" version = "1.0.4" @@ -7858,7 +7809,7 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6779878362b9bacadc7893eac76abe69612e8837ef746573c4a5239daf11990b" dependencies = [ - "bindgen", + "bindgen 0.65.1", ] [[package]] @@ -7880,15 +7831,20 @@ checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" [[package]] name = "variadics_please" -version = "1.1.0" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +checksum = "b53d0f7f2d0182759a549f1f313ce16b07d8f9ba93098157b5fa10ee3e61117f" dependencies = [ - "proc-macro2", "quote", - "syn 2.0.119", + "unsynn", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -7902,14 +7858,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" [[package]] -name = "visibility" -version = "0.1.1" +name = "video-rs" +version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" +checksum = "2633ec4c2a8aeb7c0e970f75ba99122a75841e9f7b34d5225366d0e61a870a8c" dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", + "ffmpeg-next", + "tracing", + "url", +] + +[[package]] +name = "voracious_radix_sort" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446e7ffcb6c27a71d05af7e51ef2ee5b71c48424b122a832f2439651e1914899" +dependencies = [ + "rayon", ] [[package]] @@ -8154,7 +8119,7 @@ dependencies = [ "annotate-snippets", "derive_more", "half", - "itertools", + "itertools 0.14.0", "num-traits", "proc-macro2", "quote", @@ -8170,7 +8135,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d321ea9e1cc0a2e3de41c02e3a7586695a9c03beccb5460e938a7ccf00cb612" dependencies = [ - "itertools", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.119", @@ -8191,7 +8156,37 @@ dependencies = [ "hashbrown 0.16.1", "js-sys", "log", - "naga", + "naga 29.0.4", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core 29.0.4", + "wgpu-hal 29.0.4", + "wgpu-types 29.0.4", +] + +[[package]] +name = "wgpu" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d8f4bd44d92da5270f03409dba9f952dab24f128e05d6a554926101d1bf9114" +dependencies = [ + "arrayvec", + "bitflags 2.13.1", + "bytemuck", + "cfg-if 1.0.4", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "js-sys", + "log", + "naga 30.0.0", "parking_lot", "portable-atomic", "profiling", @@ -8201,9 +8196,9 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-sys", - "wgpu-core", - "wgpu-hal", - "wgpu-types", + "wgpu-core 30.0.0", + "wgpu-hal 30.0.0", + "wgpu-types 30.0.0", ] [[package]] @@ -8222,22 +8217,56 @@ dependencies = [ "hashbrown 0.16.1", "indexmap", "log", - "naga", + "naga 29.0.4", + "once_cell", + "parking_lot", + "portable-atomic", + "profiling", + "raw-window-handle", + "rustc-hash 1.1.0", + "smallvec", + "thiserror 2.0.20", + "wgpu-core-deps-apple 29.0.4", + "wgpu-core-deps-emscripten 29.0.4", + "wgpu-core-deps-windows-linux-android 29.0.4", + "wgpu-hal 29.0.4", + "wgpu-naga-bridge 29.0.4", + "wgpu-types 29.0.4", +] + +[[package]] +name = "wgpu-core" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08763620e76fc980bca7bf84de82568614487a53172dd968d89187282eb87fa2" +dependencies = [ + "arrayvec", + "bit-set 0.10.0", + "bit-vec 0.9.1", + "bitflags 2.13.1", + "bytemuck", + "cfg_aliases", + "document-features", + "hashbrown 0.17.1", + "indexmap", + "log", + "naga 30.0.0", + "naga-types", "once_cell", "parking_lot", "portable-atomic", "profiling", "raw-window-handle", - "rustc-hash", + "rustc-hash 1.1.0", "smallvec", "thiserror 2.0.20", - "wgpu-core-deps-apple", - "wgpu-core-deps-emscripten", + "wgpu-core-deps-apple 30.0.0", + "wgpu-core-deps-emscripten 30.0.0", "wgpu-core-deps-wasm", - "wgpu-core-deps-windows-linux-android", - "wgpu-hal", - "wgpu-naga-bridge", - "wgpu-types", + "wgpu-core-deps-windows-linux-android 30.0.0", + "wgpu-hal 30.0.0", + "wgpu-naga-bridge 30.0.0", + "wgpu-types 30.0.0", ] [[package]] @@ -8246,7 +8275,16 @@ version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f5e39e26c4c0e07589e67d18546cf79ff45383659fc72fca4dd293358a0347f3" dependencies = [ - "wgpu-hal", + "wgpu-hal 29.0.4", +] + +[[package]] +name = "wgpu-core-deps-apple" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3283b6da70d7fc892de95840215aa36381b5d0cbc479e88ee2b173c7b992b225" +dependencies = [ + "wgpu-hal 30.0.0", ] [[package]] @@ -8255,16 +8293,25 @@ version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "01e09be551dc939498bdd5f6b2c66e55ab275dad25825267a08605a80fc9f0af" dependencies = [ - "wgpu-hal", + "wgpu-hal 29.0.4", +] + +[[package]] +name = "wgpu-core-deps-emscripten" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85dcf2b2e5c2afb9eda64c570a87a4e570304bf39e52eddbaaf222d655b656ad" +dependencies = [ + "wgpu-hal 30.0.0", ] [[package]] name = "wgpu-core-deps-wasm" -version = "29.0.4" +version = "30.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af1fb1798be2a912497d4c224f72d39bb0cb34af50e8bcc29865bc339c943059" +checksum = "2b6c13228950f36a56023976a6c4ee6714b27fb40e3d52a33f7d3bb6d109c0b1" dependencies = [ - "wgpu-hal", + "wgpu-hal 30.0.0", ] [[package]] @@ -8273,7 +8320,16 @@ version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e592c1bbef6ad047647ae6e666ebd8cee7a32bb4544d9700ec96cbf73230257" dependencies = [ - "wgpu-hal", + "wgpu-hal 29.0.4", +] + +[[package]] +name = "wgpu-core-deps-windows-linux-android" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f76bc9c1c186ff3d9054e0d224c93c8c1c79d6653907c5249a5c1ea1a2cb1e43" +dependencies = [ + "wgpu-hal 30.0.0", ] [[package]] @@ -8299,12 +8355,67 @@ dependencies = [ "js-sys", "khronos-egl", "libc", - "libloading 0.8.9", + "libloading", + "log", + "naga 29.0.4", + "ndk-sys", + "objc2 0.6.4", + "objc2-core-foundation", + "objc2-foundation 0.3.2", + "objc2-metal 0.3.2", + "objc2-quartz-core 0.3.2", + "once_cell", + "ordered-float", + "parking_lot", + "portable-atomic", + "portable-atomic-util", + "profiling", + "range-alloc", + "raw-window-handle", + "raw-window-metal", + "renderdoc-sys", + "smallvec", + "thiserror 2.0.20", + "wasm-bindgen", + "wayland-sys", + "web-sys", + "wgpu-naga-bridge 29.0.4", + "wgpu-types 29.0.4", + "windows 0.62.2", + "windows-core 0.62.2", + "windows-result 0.4.1", +] + +[[package]] +name = "wgpu-hal" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf765132d8d5f50e192e7880464890c13f4e7457aafe8e5466e8174586e9f101" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set 0.10.0", + "bitflags 2.13.1", + "block2 0.6.2", + "bytemuck", + "cfg-if 1.0.4", + "cfg_aliases", + "glow", + "glutin_wgl_sys", + "gpu-allocator", + "hashbrown 0.17.1", + "js-sys", + "khronos-egl", + "libc", + "libloading", "log", - "naga", + "naga 30.0.0", + "naga-types", "ndk-sys", "objc2 0.6.4", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation 0.3.2", "objc2-metal 0.3.2", "objc2-quartz-core 0.3.2", @@ -8319,12 +8430,13 @@ dependencies = [ "raw-window-metal", "renderdoc-sys", "smallvec", + "static_assertions", "thiserror 2.0.20", "wasm-bindgen", "wayland-sys", "web-sys", - "wgpu-naga-bridge", - "wgpu-types", + "wgpu-naga-bridge 30.0.0", + "wgpu-types 30.0.0", "windows 0.62.2", "windows-core 0.62.2", "windows-result 0.4.1", @@ -8336,8 +8448,31 @@ version = "29.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "95226013f547544b223281cd16a4fb549aa9dcb562adbda0faae4c73ffbbc161" dependencies = [ - "naga", - "wgpu-types", + "naga 29.0.4", + "wgpu-types 29.0.4", +] + +[[package]] +name = "wgpu-naga-bridge" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9eaac644e5008925c78567d272b9d66ef83da55a53cc17fc7daade7bb6e66e5" +dependencies = [ + "naga 30.0.0", + "wgpu-types 30.0.0", +] + +[[package]] +name = "wgpu-types" +version = "28.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e18308757e594ed2cd27dddbb16a139c42a683819d32a2e0b1b0167552f5840c" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "web-sys", ] [[package]] @@ -8351,7 +8486,23 @@ dependencies = [ "js-sys", "log", "raw-window-handle", + "web-sys", +] + +[[package]] +name = "wgpu-types" +version = "30.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a9c93c2b35edde326df60ffdee4c0f5864eac3011d6768b70d43f028ad93565" +dependencies = [ + "bitflags 2.13.1", + "bytemuck", + "js-sys", + "log", + "naga-types", + "raw-window-handle", "serde", + "static_assertions", "web-sys", ] @@ -8363,7 +8514,7 @@ checksum = "946419efa46664a8070c512fb358ac4acfc6e6215c2396ae5afeb9b72dd6ca7d" dependencies = [ "annotate-snippets", "derive_more", - "itertools", + "itertools 0.14.0", "lalrpop", "lalrpop-util", "lexical", @@ -8379,7 +8530,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7aed068ca1dc8fb182f574c9f2ace45ec988c71ba050f6dd334883fe3b8a88e2" dependencies = [ "half", - "itertools", + "itertools 0.14.0", "num-traits", ] @@ -8395,6 +8546,16 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "wide" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de2aaf408e58689c2096682331b1f42bb2d9f2ed6b11560407d023cd0a6c634e" +dependencies = [ + "bytemuck", + "safe_arch", +] + [[package]] name = "winapi" version = "0.3.9" @@ -8426,15 +8587,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windowfunctions" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90628d739333b7c5d2ee0b70210b97b8cddc38440c682c96fd9e2c24c2db5f3a" -dependencies = [ - "num-traits", -] - [[package]] name = "windows" version = "0.58.0" @@ -8618,15 +8770,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.59.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" -dependencies = [ - "windows-targets 0.52.6", -] - [[package]] name = "windows-sys" version = "0.60.2" @@ -8897,9 +9040,6 @@ name = "winnow" version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" -dependencies = [ - "memchr", -] [[package]] name = "winnow" @@ -8916,12 +9056,6 @@ version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wmidi" -version = "4.0.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b1b1e28ac6301b1c097d21c11130090f7ecc5db9d5ef4e52595ab942f8d76d5" - [[package]] name = "writeable" version = "0.6.4" @@ -8958,7 +9092,7 @@ dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "libloading 0.8.9", + "libloading", "once_cell", "rustix 1.1.4", "x11rb-protocol", diff --git a/Cargo.toml b/Cargo.toml index 2bae50b6..efcaed04 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,18 +12,22 @@ default = ["wayland", "x11"] wayland = ["processing_render/wayland"] x11 = ["processing_render/x11"] webcam = ["dep:processing_webcam"] +video = ["dep:processing_video"] +physics = ["dep:processing_physics"] cuda = ["dep:processing_cuda", "processing_cuda/cuda"] [workspace] resolver = "3" members = ["crates/*", "tools/*"] +# TEMP(bevy-020): midi/audio pin bevy 0.19 +exclude = ["crates/processing_audio", "crates/processing_midi"] [workspace.lints.clippy] type_complexity = "allow" too_many_arguments = "allow" [workspace.dependencies] -bevy = { git = "https://github.com/processing/bevy", branch = "main", default-features = false, features = [ +bevy = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase", default-features = false, features = [ # 2d "2d_bevy_render", "default_app", @@ -53,10 +57,13 @@ bevy = { git = "https://github.com/processing/bevy", branch = "main", default-fe "pan_camera", ] } -bevy_naga_reflect = { git = "https://github.com/tychedelia/bevy_naga_reflect" } +bevy_naga_reflect = { path = "/Users/char/src/github.com/tychedelia/bevy_naga_reflect-020" } bevy_cuda = { git = "https://github.com/tychedelia/bevy_cuda" } naga = { version = "29", features = ["wgsl-in"] } -wesl = { version = "0.3", default-features = false } +# naga-ext: accept naga's storage-texture format extensions (r16float etc.) +# beyond the base WGSL list — voxel T-field kernels write +# texture_storage_3d. +wesl = { version = "0.3", default-features = false, features = ["naga-ext"] } pyo3 = { git = "https://github.com/PyO3/pyo3", branch = "main" } pyo3-introspection = { git = "https://github.com/PyO3/pyo3", branch = "main" } processing = { path = "." } @@ -64,10 +71,13 @@ processing_core = { path = "crates/processing_core" } 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" } +# TEMP(bevy-020): midi/audio pin bevy 0.19 +# 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_physics = { path = "crates/processing_physics" } +processing_video = { path = "crates/processing_video" } processing_webcam = { path = "crates/processing_webcam" } rand = "0.10" rand_distr = "0.6" @@ -76,9 +86,12 @@ rand_distr = "0.6" bevy = { workspace = true } processing_core = { workspace = true } processing_render = { workspace = true } -processing_midi = { workspace = true } -processing_audio = { workspace = true } +# TEMP(bevy-020) +# processing_midi = { workspace = true } +# processing_audio = { workspace = true } processing_input = { workspace = true } +processing_physics = { workspace = true, optional = true } +processing_video = { workspace = true, optional = true } processing_webcam = { workspace = true, optional = true } processing_cuda = { workspace = true, optional = true } tracing = "0.1" @@ -98,22 +111,22 @@ 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" } +bevy = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase" } [patch.crates-io] -bevy = { git = "https://github.com/processing/bevy", branch = "main" } +bevy = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase" } # 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" } +bevy_app = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_app" } +bevy_asset = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_asset" } +bevy_diagnostic = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_diagnostic" } +bevy_ecs = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_ecs" } +bevy_log = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_log" } +bevy_math = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_math" } +bevy_platform = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_platform" } +bevy_reflect = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_reflect" } +bevy_time = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_time" } +bevy_transform = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_transform" } +bevy_utils = { path = "/Users/char/src/github.com/bevyengine/bevy-tib-rebase/crates/bevy_utils" } [[example]] name = "rectangle" @@ -163,6 +176,11 @@ path = "examples/midi.rs" name = "gltf_load" path = "examples/gltf_load.rs" +[[example]] +name = "video" +path = "examples/video.rs" +required-features = ["video"] + [[example]] name = "webcam" path = "examples/webcam.rs" @@ -212,10 +230,54 @@ path = "examples/camera_controllers.rs" name = "compute_readback" path = "examples/compute_readback.rs" +[[example]] +name = "scan_test" +path = "examples/scan_test.rs" + +[[example]] +name = "grid_test" +path = "examples/grid_test.rs" + +[[example]] +name = "flock_grid_test" +path = "examples/flock_grid_test.rs" + [[example]] name = "alias_spike" path = "examples/alias_spike.rs" +[[example]] +name = "map_test" +path = "examples/map_test.rs" + +[[example]] +name = "combine_mix_test" +path = "examples/combine_mix_test.rs" + +[[example]] +name = "lookup_test" +path = "examples/lookup_test.rs" + +[[example]] +name = "glue_test" +path = "examples/glue_test.rs" + +[[example]] +name = "sort_test" +path = "examples/sort_test.rs" + +[[example]] +name = "field_falloff_test" +path = "examples/field_falloff_test.rs" + +[[example]] +name = "group_compact_test" +path = "examples/group_compact_test.rs" + +[[example]] +name = "reduce_test" +path = "examples/reduce_test.rs" + [[example]] name = "particles_basic" path = "examples/particles_basic.rs" diff --git a/assets/shaders/plexus_link.wesl b/assets/shaders/plexus_link.wesl index 9b375f0b..0b701255 100644 --- a/assets/shaders/plexus_link.wesl +++ b/assets/shaders/plexus_link.wesl @@ -1,3 +1,17 @@ +// Proximity linker with per-edge alpha falloff — the Generative-Gestaltung +// "lissajous, all points connected" effect (M_2_5_01), on the GPU. +// +// For each source particle we walk the spatial grid and, for every OTHER +// particle within `connection_radius`, add a line whose alpha falls off with +// the edge's length: a = (1 / (d/R + 1))^ramp. Short links are near-opaque, +// long links fade out. +// +// The dynamic topology is handled by `processing::prims`: the sketch applies +// this kernel with `p.apply(link, primitives=edges)` and the engine takes +// care of counts, capacity, and indirect drawing. + +import { processing::prims }; + struct GridParams { grid_min: vec3, cell_size: f32, @@ -9,23 +23,19 @@ struct GridParams { struct Params { connection_radius: f32, - connection_ramp: f32, // falloff exponent - line_alpha: f32, // opacity scale - max_links: u32, // per-particle edge cap + connection_ramp: f32, // falloff exponent (p5 uses 6) + line_alpha: f32, // overall opacity scale (p5's lineAlpha) + _pad: f32, } // 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>; +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var color: 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; +@group(0) @binding(2) var offsets: array; +@group(0) @binding(3) var sorted: array; +@group(0) @binding(4) var params: Params; +@group(0) @binding(5) var gp: GridParams; fn cell_coords(p: vec3, grid_min: vec3, cell_size: f32, dims: vec3) -> vec3 { let rel = (p - grid_min) / cell_size; @@ -48,24 +58,12 @@ 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; @@ -81,7 +79,6 @@ fn main(@builtin(global_invocation_id) gid: vec3) { 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++) { @@ -96,17 +93,16 @@ fn main(@builtin(global_invocation_id) gid: vec3) { let d2 = dot(diff, diff); if d2 > r2 { continue; } - if emitted >= params.max_links { return; } - emitted += 1u; - + // Per-edge alpha: short links solid, long links fade out. let d = sqrt(d2); - let a = pow(1.0 / (d / radius + 1.0), params.connection_ramp) * params.line_alpha; + 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); - } + var v0 = prims::vertex(pos); + v0.color = vec4(rgb, a); + var v1 = prims::vertex(pj); + v1.color = vec4(load_rgb(j), a); + prims::line(v0, v1); } } } diff --git a/assets/video/file_example_MP4_640_3MG.mp4 b/assets/video/file_example_MP4_640_3MG.mp4 new file mode 100644 index 00000000..61a12bca Binary files /dev/null and b/assets/video/file_example_MP4_640_3MG.mp4 differ diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 41510f34..b0fd3d13 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -38,6 +38,8 @@ pub enum ProcessingError { GltfLoadError(String), #[error("Webcam not connected")] WebcamNotConnected, + #[error("Video not loaded")] + VideoNotLoaded, #[error("Shader compilation error: {0}")] ShaderCompilationError(String), #[error("Shader not found")] diff --git a/crates/processing_ffi/Cargo.toml b/crates/processing_ffi/Cargo.toml index 16d41364..fa04f09b 100644 --- a/crates/processing_ffi/Cargo.toml +++ b/crates/processing_ffi/Cargo.toml @@ -11,14 +11,18 @@ name = "processing" crate-type = ["cdylib", "staticlib"] [features] -default = [] +default = ["video", "physics"] wayland = ["processing/wayland"] x11 = ["processing/x11"] webcam = ["processing/webcam"] cuda = ["processing/cuda"] +video = ["processing/video", "dep:processing_video"] +physics = ["processing/physics", "dep:processing_physics"] [dependencies] processing = { workspace = true } +processing_physics = { workspace = true, optional = true } +processing_video = { workspace = true, optional = true } bevy = { workspace = true } half = "2.7" diff --git a/crates/processing_ffi/build.rs b/crates/processing_ffi/build.rs index e94d8186..14aa12eb 100644 --- a/crates/processing_ffi/build.rs +++ b/crates/processing_ffi/build.rs @@ -18,7 +18,11 @@ fn main() { .expect("Unable to generate bindings") .write_to_file(&output_file); - println!("cargo:rerun-if-changed=src/lib.rs"); + // The whole source tree, not just lib.rs: exports live in modules + // (video.rs, canvas.rs, ...) too, and naming only lib.rs meant a new or + // changed export in any of them left a stale header behind -- which shows + // up much later as a jextract binding that does not match the library. + println!("cargo:rerun-if-changed=src"); println!("cargo:rerun-if-changed=cbindgen.toml"); println!( "cargo:warning=Generated header at: {}", diff --git a/crates/processing_ffi/src/canvas.rs b/crates/processing_ffi/src/canvas.rs new file mode 100644 index 00000000..e0a37d9d --- /dev/null +++ b/crates/processing_ffi/src/canvas.rs @@ -0,0 +1,179 @@ +//! Offscreen canvases, state (accumulator) canvases, and canvas compositing. +//! +//! A **state canvas** is the feedback/simulation counterpart to a picture +//! canvas: float format, no bloom, no tonemapping, and LINEAR color writes. +//! Post-processing writes back through the view target, so on a canvas whose +//! pixels are data (a reaction-diffusion field, an accumulator) it silently +//! re-transforms the state every frame; and sRGB color resolution means +//! `fill(0.5)` would store 0.21. Both are fatal for patch-style work and both +//! are off by construction here. + +use bevy::prelude::Entity; +use bevy::render::render_resource::TextureFormat; +use processing::prelude::{ + filter_composite, filter_feedback, filter_set, graphics_apply_filter, graphics_create_ex, + graphics_flush, shader_value::ShaderValue, surface_create_offscreen, +}; + +use crate::error; + +/// Composite mode: REPLACE — Processing's `copy()` (filters/composite.wgsl). +pub const COMPOSITE_MODE_REPLACE: u32 = 9; +/// Composite mode: MASK — Processing's `mask()`. +pub const COMPOSITE_MODE_MASK: u32 = 10; + +fn texture_format(hdr: bool) -> TextureFormat { + if hdr { + TextureFormat::Rgba16Float + } else { + TextureFormat::Rgba8UnormSrgb + } +} + +/// Create an offscreen render surface (an image-backed target, no window). +/// Pair with `processing_graphics_create_ex` to get a drawable canvas. +/// Returns the surface entity id, or 0 on error. +/// +/// SAFETY: +/// - Init has been called. +/// - Called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_surface_create_offscreen( + width: u32, + height: u32, + scale_factor: f32, + hdr: bool, +) -> u64 { + error::clear_error(); + error::check(|| surface_create_offscreen(width, height, scale_factor, texture_format(hdr))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Create a graphics context with explicit format and state semantics. +/// +/// `hdr`: float (Rgba16Float) vs Rgba8UnormSrgb target. +/// `state`: when true the canvas is a STATE canvas — post-process-free +/// (bloom/tonemapping never applied, and `bloom()` on it is ignored with a +/// warning) and linear-color, so drawn values round-trip exactly. +/// Returns the graphics entity id, or 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_create_ex( + surface_id: u64, + width: u32, + height: u32, + hdr: bool, + state: bool, +) -> u64 { + error::clear_error(); + let surface_entity = Entity::from_bits(surface_id); + error::check(|| { + graphics_create_ex( + surface_entity, + width, + height, + texture_format(hdr), + state, + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Composite a texture-bearing source onto a graphics target through the +/// built-in composite filter — the way to get an offscreen buffer onto the +/// main canvas. +/// +/// `src_id` is the texture entity: an image, or an offscreen graphics' +/// SURFACE entity. `src_graphics_id` is that graphics (or 0), flushed first so +/// the latest content is what gets sampled. `src_rect`/`dst_rect` are uv-space +/// `[x0, y0, x1, y1]`, or null for the full extent. +/// +/// SAFETY: +/// - Init has been called. +/// - `src_rect`/`dst_rect` are null or valid for 4 f32 reads. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_composite( + dst_graphics_id: u64, + src_id: u64, + src_graphics_id: u64, + mode: u32, + src_rect: *const f32, + dst_rect: *const f32, + opacity: f32, +) { + error::clear_error(); + let read_rect = |p: *const f32| -> [f32; 4] { + if p.is_null() { + [0.0, 0.0, 1.0, 1.0] + } else { + let s = unsafe { std::slice::from_raw_parts(p, 4) }; + [s[0], s[1], s[2], s[3]] + } + }; + let src_rect = read_rect(src_rect); + let dst_rect = read_rect(dst_rect); + error::check(|| { + if src_graphics_id != 0 { + graphics_flush(Entity::from_bits(src_graphics_id))?; + } + let filter = filter_composite()?; + filter_set( + filter, + "src", + ShaderValue::Texture(Entity::from_bits(src_id)), + )?; + filter_set(filter, "mode", ShaderValue::UInt(mode))?; + filter_set(filter, "opacity", ShaderValue::Float(opacity))?; + filter_set(filter, "src_rect", ShaderValue::Float4(src_rect))?; + filter_set(filter, "dst_rect", ShaderValue::Float4(dst_rect))?; + graphics_apply_filter(Entity::from_bits(dst_graphics_id), filter) + }); +} + +/// Built-in feedback pass: re-sample this graphics' previous frame through a +/// zoom/rotate/offset transform scaled by `decay`, writing it back. On a +/// canvas you never clear, call at the start of the frame and draw new content +/// on top to get feedback trails. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_feedback( + graphics_id: u64, + decay: f32, + zoom: f32, + angle: f32, + offset_x: f32, + offset_y: f32, +) { + error::clear_error(); + error::check(|| { + let filter = filter_feedback()?; + filter_set(filter, "decay", ShaderValue::Float(decay))?; + filter_set(filter, "zoom", ShaderValue::Float(zoom))?; + filter_set(filter, "angle", ShaderValue::Float(angle))?; + filter_set( + filter, + "offset", + ShaderValue::Float2([offset_x, offset_y]), + )?; + graphics_apply_filter(Entity::from_bits(graphics_id), filter) + }); +} + +/// The built-in feedback filter entity (for direct `filter_set` tuning). +/// Returns 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_feedback() -> u64 { + error::clear_error(); + error::check(filter_feedback) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// The built-in composite filter entity. Returns 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_filter_composite() -> u64 { + error::clear_error(); + error::check(filter_composite) + .map(|e| e.to_bits()) + .unwrap_or(0) +} diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 731722cc..2e591949 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -7,10 +7,20 @@ use processing::prelude::{error::ProcessingError, shader_value::ShaderValue, *}; use crate::color::Color; +mod canvas; mod color; mod error; - -unsafe fn cstr_to_str<'a>(ptr: *const std::ffi::c_char) -> Result<&'a str, ProcessingError> { +mod texture3d; +#[cfg(feature = "physics")] +mod physics; +#[cfg(feature = "video")] +mod video; +#[cfg(all(feature = "video", target_os = "macos"))] +mod video_macos; + +pub(crate) 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())) @@ -27,6 +37,27 @@ pub extern "C" fn processing_init() { error::check(|| init(Config::default())); } +/// Initialize libProcessing with an asset root directory. Relative paths +/// passed to `processing_shader_load` (and image/gltf loads) resolve +/// against this directory. Same constraints as `processing_init`. +/// +/// SAFETY: +/// - `asset_root` must be non-null. +/// - This is called from the main thread if the platform requires it. +/// - This can only be called once. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_init_with_asset_root(asset_root: *const std::ffi::c_char) { + error::clear_error(); + error::check(|| { + let asset_root = unsafe { cstr_to_str(asset_root) }?; + let mut config = Config::default(); + if !asset_root.is_empty() { + config.set(ConfigKey::AssetRootPath, asset_root.to_string()); + } + init(config) + }); +} + /// Create a WebGPU surface from a macOS NSWindow handle. /// /// SAFETY: @@ -193,6 +224,26 @@ pub extern "C" fn processing_surface_resize(window_id: u64, width: u32, height: error::check(|| surface_resize(window_entity, width, height)); } +/// Set the swapchain present mode for a window surface: `vsync` false selects +/// `AutoNoVsync`, uncapping the draw loop from the display's refresh rate. +/// +/// Windows are created vsynced, so this only ever has to be called to turn +/// vsync off — the case an offline render wants, where the loop should run as +/// fast as render + capture allow and nothing is watching the window. Tearing +/// cannot reach a recording: `processing_record_capture` reads back the canvas +/// texture, never the swapchain. +/// +/// SAFETY: +/// - Init and surface_create have been called. +/// - window_id is a valid ID returned from surface_create. +/// - This is called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_surface_set_vsync(window_id: u64, vsync: bool) { + error::clear_error(); + let window_entity = Entity::from_bits(window_id); + error::check(|| surface_set_vsync(window_entity, vsync)); +} + /// Set the background color for the given graphics context. /// /// SAFETY: @@ -282,6 +333,36 @@ pub extern "C" fn processing_end_draw(graphics_id: u64) { error::check(|| graphics_end_draw(graphics_entity)); } +/// Whether a canvas has draw commands waiting for a flush. Callers that flush +/// defensively before sampling a canvas can skip the flush (a whole +/// `app.update()`) when this is false. +/// +/// 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_has_pending(graphics_id: u64) -> bool { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_has_pending(graphics_entity)).unwrap_or(true) +} + +/// End the current draw WITHOUT forcing a GPU update: the canvas is armed to +/// present on the next update instead. For window canvases only — a single +/// update presents every armed window, so N windows cost one update per frame +/// rather than N. +/// +/// SAFETY: +/// - Init and graphics_create have been called. +/// - 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_arm_present(graphics_id: u64) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| graphics_arm_present(graphics_entity)); +} + /// Shuts down internal resources with given exit code, but does *not* terminate the process. /// /// SAFETY: @@ -290,6 +371,8 @@ pub extern "C" fn processing_end_draw(graphics_id: u64) { #[unsafe(no_mangle)] pub extern "C" fn processing_exit(exit_code: u8) { error::clear_error(); + #[cfg(feature = "video")] + video::finish_on_exit(); error::check(|| exit(exit_code)); } @@ -875,6 +958,40 @@ pub extern "C" fn processing_curve( }); } +/// Draw a box. +#[unsafe(no_mangle)] +pub extern "C" fn processing_box(graphics_id: u64, width: f32, height: f32, depth: f32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Box { + width, + height, + depth, + }, + ) + }); +} + +/// Draw a sphere. +#[unsafe(no_mangle)] +pub extern "C" fn processing_sphere(graphics_id: u64, radius: f32, sectors: u32, stacks: u32) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Sphere { + radius, + sectors, + stacks, + }, + ) + }); +} + /// Draw a cylinder. #[unsafe(no_mangle)] pub extern "C" fn processing_cylinder(graphics_id: u64, radius: f32, height: f32, detail: u32) { @@ -1114,6 +1231,12 @@ pub unsafe extern "C" fn processing_create_font(name_ptr: *const std::ffi::c_cha error::check(|| font_create(&name).map(|e| e.to_bits())).unwrap_or(0) } +#[unsafe(no_mangle)] +pub extern "C" fn processing_font_destroy(font_id: u64) { + error::clear_error(); + error::check(|| font_destroy(Entity::from_bits(font_id))); +} + /// Query the number of variable font axes for a font. /// Returns 0 if the font is not variable or not found. #[unsafe(no_mangle)] @@ -1593,6 +1716,12 @@ pub unsafe extern "C" fn processing_image_load(path: *const std::ffi::c_char) -> .unwrap_or(0) } +#[unsafe(no_mangle)] +pub extern "C" fn processing_image_destroy(image_id: u64) { + error::clear_error(); + error::check(|| image_destroy(Entity::from_bits(image_id))); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_image_resize(image_id: u64, new_width: u32, new_height: u32) { error::clear_error(); @@ -1954,6 +2083,27 @@ pub extern "C" fn processing_ortho( error::check(|| graphics_ortho(graphics_entity, left, right, bottom, top, near, far)); } +/// Set a glFrustum-style asymmetric (off-axis) perspective projection. +/// `left`/`right`/`bottom`/`top` bound the view volume on the NEAR plane, +/// matching classic Processing/OpenGL `frustum()`. Requires 0 < near < far. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +#[unsafe(no_mangle)] +pub extern "C" fn processing_frustum( + 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_frustum(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(); @@ -2765,6 +2915,101 @@ pub extern "C" fn processing_geometry_sphere(radius: f32, sectors: u32, stacks: .unwrap_or(0) } +/// Tessellate and extrude `text` into a 3D geometry, honoring the graphics +/// context's current text font, size, and style. `x`/`y` position the +/// baseline as in text(); `depth` is the extrusion depth. Returns the +/// geometry entity, or 0 on error. +/// +/// SAFETY: +/// - graphics_id is a valid ID returned from graphics_create. +/// - text is a valid, NUL-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_graphics_text_to_geometry( + graphics_id: u64, + text: *const std::ffi::c_char, + x: f32, + y: f32, + depth: f32, +) -> u64 { + error::clear_error(); + error::check(|| { + let text = unsafe { cstr_to_str(text) }?; + let mesh = graphics_text_to_model(Entity::from_bits(graphics_id), text, x, y, depth)?; + Ok(geometry_create_from_mesh(mesh)?.to_bits()) + }) + .unwrap_or(0) +} + +/// Destroy a light created by any of the light-create functions. +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_destroy(light_id: u64) { + error::clear_error(); + error::check(|| light_destroy(Entity::from_bits(light_id))); +} + +/// Aim a light: (x, y, z) is the direction the light travels (Processing's +/// directionalLight convention). +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_set_direction(light_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + error::check(|| light_set_direction(Entity::from_bits(light_id), Vec3::new(x, y, z))); +} + +/// Enable or disable shadow casting for a directional light. +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_set_shadows(light_id: u64, enabled: bool) { + error::clear_error(); + error::check(|| light_set_shadows(Entity::from_bits(light_id), enabled)); +} + +/// Enable or disable contact-shadow casting for a directional light. +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_set_contact_shadows(light_id: u64, enabled: bool) { + error::clear_error(); + error::check(|| light_set_contact_shadows(Entity::from_bits(light_id), enabled)); +} + +/// Set a directional light's shadow-map depth and normal bias. +#[unsafe(no_mangle)] +pub extern "C" fn processing_light_set_shadow_bias( + light_id: u64, + depth_bias: f32, + normal_bias: f32, +) { + error::clear_error(); + error::check(|| { + light_set_shadow_bias(Entity::from_bits(light_id), depth_bias, normal_bias) + }); +} + +/// Enable or disable screen-space contact shadows on a canvas' camera. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_contact_shadows( + graphics_id: u64, + enabled: bool, + linear_steps: u32, + thickness: f32, + length: f32, +) { + error::clear_error(); + error::check(|| { + graphics_set_contact_shadows( + Entity::from_bits(graphics_id), + enabled, + linear_steps, + thickness, + length, + ) + }); +} + +/// Enable or disable temporal antialiasing on a canvas' camera. +#[unsafe(no_mangle)] +pub extern "C" fn processing_graphics_temporal_aa(graphics_id: u64, enabled: bool) { + error::clear_error(); + error::check(|| graphics_set_temporal_aa(Entity::from_bits(graphics_id), enabled)); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_light_create_directional( graphics_id: u64, @@ -2932,6 +3177,12 @@ pub extern "C" fn processing_material_set_depth_write(mat_id: u64, value: bool) error::check(|| material_set_depth_write(Entity::from_bits(mat_id), value)); } +#[unsafe(no_mangle)] +pub extern "C" fn processing_material_set_depth_bias(mat_id: u64, value: f32) { + error::clear_error(); + error::check(|| material_set_depth_bias(Entity::from_bits(mat_id), value)); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_material_set_custom_blend_mode( mat_id: u64, @@ -3431,6 +3682,15 @@ pub extern "C" fn processing_compute_dispatch(compute_id: u64, x: u32, y: u32, z error::check(|| compute_dispatch(Entity::from_bits(compute_id), x, y, z)); } +/// Dispatch without pumping an engine update first. See +/// `compute_dispatch_quiet`: submission order still guarantees pass ordering, +/// but resources created since the last update may not be bindable yet. +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_dispatch_quiet(compute_id: u64, x: u32, y: u32, z: u32) { + error::clear_error(); + error::check(|| compute_dispatch_quiet(Entity::from_bits(compute_id), x, y, z)); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_compute_destroy(compute_id: u64) { error::clear_error(); @@ -3752,6 +4012,12 @@ pub unsafe extern "C" fn processing_gltf_load( .unwrap_or(0) } +#[unsafe(no_mangle)] +pub extern "C" fn processing_gltf_destroy(gltf_id: u64) { + error::clear_error(); + error::check(|| gltf_destroy(Entity::from_bits(gltf_id))); +} + /// # Safety /// - `name` is a valid null-terminated C string. #[unsafe(no_mangle)] @@ -3768,6 +4034,53 @@ pub unsafe extern "C" fn processing_gltf_geometry( .unwrap_or(0) } +/// Spawn an animation pose pool from a loaded GLTF: `k` paused puppet +/// instances of the scene, seeked along the named clip at pose/k of its +/// duration. Their joint matrices drive skinned particle instancing (see +/// `processing_particles_set_skin_pool`). Returns the pool id, or 0 on error. +/// +/// # Safety +/// - `clip_name` is a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_gltf_animation_pool( + gltf_id: u64, + clip_name: *const std::ffi::c_char, + k: u32, +) -> u64 { + error::clear_error(); + error::check(|| { + let clip_name = unsafe { cstr_to_str(clip_name) }?; + gltf_animation_pool(Entity::from_bits(gltf_id), clip_name, k) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_gltf_animation_pool_destroy(pool_id: u64) { + error::clear_error(); + error::check(|| gltf_animation_pool_destroy(Entity::from_bits(pool_id))); +} + +/// Attach an animation pose pool and a per-particle phase attribute (float, +/// phase in [0,1)) to a particle system. Instanced draws of the system are +/// then skinned: each instance takes the pool pose nearest its phase. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_set_skin_pool( + particles_id: u64, + pool_id: u64, + phase_attr_id: u64, +) { + error::clear_error(); + error::check(|| { + particles_set_skin_pool( + Entity::from_bits(particles_id), + Entity::from_bits(pool_id), + Entity::from_bits(phase_attr_id), + ) + }); +} + /// # Safety /// - `name` is a valid null-terminated C string. #[unsafe(no_mangle)] @@ -3809,6 +4122,14 @@ pub extern "C" fn processing_particles_apply(particles_id: u64, compute_id: u64) }); } +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_flock(particles_id: u64, flock_id: u64) { + error::clear_error(); + error::check(|| { + particles_flock_auto(Entity::from_bits(particles_id), Entity::from_bits(flock_id)) + }); +} + #[unsafe(no_mangle)] pub extern "C" fn processing_particles_draw(graphics_id: u64, particles_id: u64, geometry_id: u64) { error::clear_error(); @@ -3825,6 +4146,180 @@ pub extern "C" fn processing_particles_draw(graphics_id: u64, particles_id: u64, }); } +fn topology_from_u32(topology: u32) -> Result { + match topology { + 0 => Ok(geometry::Topology::PointList), + 1 => Ok(geometry::Topology::LineList), + 2 => Ok(geometry::Topology::LineStrip), + 3 => Ok(geometry::Topology::TriangleList), + 4 => Ok(geometry::Topology::TriangleStrip), + _ => Err(ProcessingError::InvalidArgument(format!( + "unknown topology {topology}" + ))), + } +} + +/// Draw `particles` with an explicit topology. `geometry_id` 0 draws raw +/// vertices with the given topology instead of instanced geometry. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_draw_topology( + graphics_id: u64, + particles_id: u64, + geometry_id: u64, + topology: u32, +) { + error::clear_error(); + let graphics_entity = Entity::from_bits(graphics_id); + error::check(|| { + graphics_record_command( + graphics_entity, + DrawCommand::Particles { + particles: Entity::from_bits(particles_id), + geometry: (geometry_id != 0).then(|| Entity::from_bits(geometry_id)), + topology: topology_from_u32(topology)?, + }, + ) + }); +} + +/// Grids are plain structs, not ECS entities, so the FFI keeps them in a +/// handle table. +static GRIDS: std::sync::Mutex<(u64, Option>)> = + std::sync::Mutex::new((0, None)); + +fn grid_get(handle: u64) -> Result { + GRIDS + .lock() + .unwrap() + .1 + .as_ref() + .and_then(|m| m.get(&handle).copied()) + .ok_or_else(|| ProcessingError::InvalidArgument(format!("unknown grid handle {handle}"))) +} + +/// Create a spatial hash grid sized to `particles_id`'s capacity. Returns a +/// grid handle (not an entity id), or 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_grid_create( + particles_id: u64, + min_x: f32, + min_y: f32, + min_z: f32, + cell_size: f32, + dims_x: u32, + dims_y: u32, + dims_z: u32, +) -> u64 { + error::clear_error(); + error::check(|| { + let capacity = particles_capacity(Entity::from_bits(particles_id))?; + let grid = grid_create( + GridParams { + min: [min_x, min_y, min_z], + cell_size, + dims: [dims_x, dims_y, dims_z], + }, + capacity, + )?; + let mut guard = GRIDS.lock().unwrap(); + guard.0 += 1; + let handle = guard.0; + guard.1.get_or_insert_with(Default::default).insert(handle, grid); + Ok(handle) + }) + .unwrap_or(0) +} + +/// Rebuild the grid's cell index from a position buffer. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_grid_build(grid_handle: u64, position_buf_id: u64) { + error::clear_error(); + error::check(|| grid_build(&grid_get(grid_handle)?, Entity::from_bits(position_buf_id))); +} + +/// Bind the grid's offsets/sorted buffers and domain uniforms onto `compute`. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_grid_bind(grid_handle: u64, compute_id: u64) { + error::clear_error(); + error::check(|| grid_bind(&grid_get(grid_handle)?, Entity::from_bits(compute_id))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_grid_destroy(grid_handle: u64) { + error::clear_error(); + error::check(|| { + let grid = GRIDS + .lock() + .unwrap() + .1 + .as_mut() + .and_then(|m| m.remove(&grid_handle)) + .ok_or_else(|| { + ProcessingError::InvalidArgument(format!("unknown grid handle {grid_handle}")) + })?; + buffer_destroy(grid.offsets)?; + buffer_destroy(grid.cursor)?; + buffer_destroy(grid.sorted) + }); +} + +/// Create a dynamic-topology primitives target over `particles_id`. +/// `topology` must be 0 (points), 1 (lines), or 3 (triangles); capacity is +/// counted in primitives. Returns the target entity id, or 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_primitives_create( + particles_id: u64, + topology: u32, + capacity_prims: u32, +) -> u64 { + error::clear_error(); + error::check(|| { + particles_primitives_create( + Entity::from_bits(particles_id), + topology_from_u32(topology)?, + capacity_prims, + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Destroy a dynamic-topology target and its internal field, buffers, reset +/// kernel, and reset shader. The source particle system is borrowed. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_primitives_destroy(target_id: u64) { + error::clear_error(); + error::check(|| particles_primitives_destroy(Entity::from_bits(target_id))); +} + +/// Apply `compute` over the target's source field with the target's buffers +/// bound under the `processing::prims` reserved names. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_primitives_apply(target_id: u64, compute_id: u64) { + error::clear_error(); + error::check(|| { + particles_primitives_apply(Entity::from_bits(target_id), Entity::from_bits(compute_id)) + }); +} + +/// The internal particle field holding the target's expanded vertices (what +/// gets drawn), or 0 on error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_primitives_field(target_id: u64) -> u64 { + error::clear_error(); + error::check(|| particles_primitives_field(Entity::from_bits(target_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Primitives the kernels attempted to add this frame, including any dropped +/// for capacity. Reads a small stat back from the GPU. +#[unsafe(no_mangle)] +pub extern "C" fn processing_particles_primitives_attempted(target_id: u64) -> u32 { + error::clear_error(); + error::check(|| particles_primitives_attempted(Entity::from_bits(target_id))).unwrap_or(0) +} + #[unsafe(no_mangle)] pub extern "C" fn processing_fill_buffer(graphics_id: u64, buffer_id: u64) { error::clear_error(); @@ -4035,7 +4530,12 @@ pub extern "C" fn processing_input_scroll(surface_id: u64, x: f32, y: f32) { pub extern "C" fn processing_input_key(surface_id: u64, key_code: u32, pressed: bool) { error::clear_error(); error::check(|| { - let kc = key_code_from_u32(key_code)?; + // Silently skip keys with no mapping (media keys, GLFW_KEY_UNKNOWN): + // matches the Rust GLFW runner's `if let Some(kc) = glfw_key_to_bevy`. + // An input callback must never take down the render loop. + let Ok(kc) = key_code_from_u32(key_code) else { + return Ok(()); + }; input_set_key(Entity::from_bits(surface_id), kc, pressed) }); } @@ -4044,7 +4544,14 @@ pub extern "C" fn processing_input_key(surface_id: u64, key_code: u32, pressed: pub extern "C" fn processing_input_char(surface_id: u64, key_code: u32, codepoint: u32) { error::clear_error(); error::check(|| { - let kc = key_code_from_u32(key_code)?; + // 0 = no associated key: char events arrive separately from key + // events on GLFW. Matches the Rust GLFW runner, which sends + // KeyCode::Unidentified for WindowEvent::Char. + let kc = if key_code == 0 { + KeyCode::Unidentified(bevy::input::keyboard::NativeKeyCode::Unidentified) + } else { + key_code_from_u32(key_code)? + }; let ch = char::from_u32(codepoint).ok_or_else(|| { ProcessingError::InvalidArgument(format!("invalid codepoint: {codepoint}")) })?; diff --git a/crates/processing_ffi/src/physics.rs b/crates/processing_ffi/src/physics.rs new file mode 100644 index 00000000..ea78ad2b --- /dev/null +++ b/crates/processing_ffi/src/physics.rs @@ -0,0 +1,291 @@ +//! Rigid-body physics (avian3d). +//! +//! Bodies are pure simulation objects: create them, step happens once per +//! frame (frame-locked, deterministic), read poses back and draw them however +//! the sketch likes — typically in bulk into a particle system's +//! position/rotation buffers for instanced drawing. + +use bevy::math::{Quat, Vec3}; +use bevy::prelude::Entity; +use processing_physics::{ + body_apply_angular_impulse, body_apply_impulse, body_create, body_create_from_geometry, + body_destroy, + body_get_linear_velocity, body_get_transform, body_set_angular_velocity, body_set_density, + body_set_friction, body_set_linear_velocity, body_set_position, body_set_restitution, + body_set_transform, read_transforms, set_gravity, set_paused, set_realtime, set_substeps, + set_timestep_hz, step, +}; + +use crate::error; + +// ── World configuration ───────────────────────────────────────────────────── + +/// Set the gravity vector (world units per second squared). +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_set_gravity(x: f32, y: f32, z: f32) { + error::clear_error(); + error::check(|| set_gravity(Vec3::new(x, y, z))); +} + +/// Frame-locked stepping: the simulation advances by exactly `1/hz` seconds +/// every rendered frame (the default, at 60 Hz). Deterministic. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_set_timestep(hz: f64) { + error::clear_error(); + error::check(|| set_timestep_hz(hz)); +} + +/// Follow the wall clock instead of frame-locked stepping (non-deterministic). +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_set_realtime() { + error::clear_error(); + error::check(set_realtime); +} + +/// Mark the start of a Processing frame: the next engine update advances the +/// simulation by one fixed step. Called once per frame by the host renderer +/// (primary canvas beginDraw); extra engine updates pumped by flushes or +/// offscreen graphics within the frame do not step again. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_step() { + error::clear_error(); + error::check(step); +} + +/// Pause or resume the simulation. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_set_paused(paused: bool) { + error::clear_error(); + error::check(|| set_paused(paused)); +} + +/// Solver substeps per physics step (default 6). More = stiffer stacks. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_set_substeps(count: u32) { + error::clear_error(); + error::check(|| set_substeps(count)); +} + +// ── Bodies ────────────────────────────────────────────────────────────────── + +/// Create a rigid body. +/// +/// `kind`: 0 = static, 1 = dynamic, 2 = kinematic. +/// `shape`: 0 = box (p0, p1, p2 = full extents), 1 = sphere (p0 = radius), +/// 2 = capsule (p0 = radius, p1 = cylinder length). +/// Returns the body id, or 0 on error. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn processing_physics_body_create( + kind: u32, + shape: u32, + p0: f32, + p1: f32, + p2: f32, + x: f32, + y: f32, + z: f32, + qx: f32, + qy: f32, + qz: f32, + qw: f32, +) -> u64 { + error::clear_error(); + error::check(|| { + body_create( + kind, + shape, + Vec3::new(p0, p1, p2), + Vec3::new(x, y, z), + Quat::from_xyzw(qx, qy, qz, qw).normalize(), + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Create a rigid body whose collider is built from a geometry's CPU mesh +/// (any geometry id: primitives, custom shapes, glTF meshes). +/// +/// `kind`: 0 = static, 1 = dynamic, 2 = kinematic. +/// `mode`: 0 = trimesh (exact; static scenery), 1 = convex hull (fast; +/// dynamic bodies), 2 = convex decomposition (accurate and dynamic-friendly, +/// expensive to build). +/// Returns the body id, or 0 on error. +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn processing_physics_body_create_from_geometry( + geometry_id: u64, + kind: u32, + mode: u32, + x: f32, + y: f32, + z: f32, + qx: f32, + qy: f32, + qz: f32, + qw: f32, +) -> u64 { + error::clear_error(); + error::check(|| { + body_create_from_geometry( + Entity::from_bits(geometry_id), + kind, + mode, + Vec3::new(x, y, z), + Quat::from_xyzw(qx, qy, qz, qw).normalize(), + ) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Remove a body from the simulation. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_destroy(body_id: u64) { + error::clear_error(); + error::check(|| body_destroy(Entity::from_bits(body_id))); +} + +/// Teleport a body (also zeroes accumulated interpolation, not velocities). +#[unsafe(no_mangle)] +#[allow(clippy::too_many_arguments)] +pub extern "C" fn processing_physics_body_set_transform( + body_id: u64, + x: f32, + y: f32, + z: f32, + qx: f32, + qy: f32, + qz: f32, + qw: f32, +) { + error::clear_error(); + error::check(|| { + body_set_transform( + Entity::from_bits(body_id), + Vec3::new(x, y, z), + Quat::from_xyzw(qx, qy, qz, qw).normalize(), + ) + }); +} + +/// Position-only teleport: keeps the current rotation and velocities. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_position(body_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + error::check(|| body_set_position(Entity::from_bits(body_id), Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_linear_velocity(body_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + error::check(|| body_set_linear_velocity(Entity::from_bits(body_id), Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_angular_velocity( + body_id: u64, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + error::check(|| body_set_angular_velocity(Entity::from_bits(body_id), Vec3::new(x, y, z))); +} + +/// Apply a one-shot linear impulse (mass-scaled velocity change) this frame. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_apply_impulse(body_id: u64, x: f32, y: f32, z: f32) { + error::clear_error(); + error::check(|| body_apply_impulse(Entity::from_bits(body_id), Vec3::new(x, y, z))); +} + +/// Apply a one-shot angular impulse this frame. +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_apply_angular_impulse( + body_id: u64, + x: f32, + y: f32, + z: f32, +) { + error::clear_error(); + error::check(|| body_apply_angular_impulse(Entity::from_bits(body_id), Vec3::new(x, y, z))); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_friction(body_id: u64, friction: f32) { + error::clear_error(); + error::check(|| body_set_friction(Entity::from_bits(body_id), friction)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_restitution(body_id: u64, restitution: f32) { + error::clear_error(); + error::check(|| body_set_restitution(Entity::from_bits(body_id), restitution)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_physics_body_set_density(body_id: u64, density: f32) { + error::clear_error(); + error::check(|| body_set_density(Entity::from_bits(body_id), density)); +} + +// ── Readback ──────────────────────────────────────────────────────────────── + +/// Write a body's pose into `out` as 7 floats: position xyz, quaternion xyzw. +/// +/// SAFETY: +/// - Init has been called. +/// - `out` is valid for 7 f32 writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_physics_body_get_transform(body_id: u64, out: *mut f32) { + error::clear_error(); + if let Some(pose) = error::check(|| body_get_transform(Entity::from_bits(body_id))) { + let out = unsafe { std::slice::from_raw_parts_mut(out, 7) }; + out.copy_from_slice(&pose); + } +} + +/// Write a body's linear velocity into `out` as 3 floats. +/// +/// SAFETY: +/// - Init has been called. +/// - `out` is valid for 3 f32 writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_physics_body_get_linear_velocity(body_id: u64, out: *mut f32) { + error::clear_error(); + if let Some(v) = error::check(|| body_get_linear_velocity(Entity::from_bits(body_id))) { + let out = unsafe { std::slice::from_raw_parts_mut(out, 3) }; + out.copy_from_slice(&v); + } +} + +/// Bulk pose readback: for each of `count` body ids, writes 7 floats +/// (position xyz, quaternion xyzw) into `out`. Destroyed bodies write +/// identity poses. Returns the number of live bodies read. +/// +/// SAFETY: +/// - Init has been called. +/// - `ids` is valid for `count` u64 reads; `out` for `count * 7` f32 writes. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_physics_read_transforms( + ids: *const u64, + count: usize, + out: *mut f32, +) -> usize { + error::clear_error(); + let ids = unsafe { std::slice::from_raw_parts(ids, count) }; + let out = unsafe { std::slice::from_raw_parts_mut(out, count * 7) }; + match error::check(|| { + let mut poses = vec![0.0f32; count * 7]; + let read = read_transforms(ids, &mut poses)?; + Ok((read, poses)) + }) { + Some((read, poses)) => { + out.copy_from_slice(&poses); + read + } + None => 0, + } +} diff --git a/crates/processing_ffi/src/texture3d.rs b/crates/processing_ffi/src/texture3d.rs new file mode 100644 index 00000000..47adc3fb --- /dev/null +++ b/crates/processing_ffi/src/texture3d.rs @@ -0,0 +1,133 @@ +//! 3D texture FFI: a volume texture writable from compute kernels +//! (`texture_storage_3d`) and sampled with trilinear filtering in +//! custom materials/filters (`texture_3d`). Bound by name like any other +//! texture via `processing_shader_set_texture` / material texture params. + +use bevy::prelude::Entity; +use bevy::render::render_resource::{Extent3d, TextureFormat}; +use processing::prelude::{ + image, image_destroy, texture3d_create, texture3d_readback, texture3d_write, +}; + +use crate::error; + +/// Map an FFI format code to a texture format. +/// +/// 0 = r16float (2 B/texel; storage write + trilinear on Metal via +/// adapter-specific format features — the voxel T-field default), +/// 1 = r32float (4 B; storage write everywhere, NOT filterable on Apple GPUs), +/// 2 = rg16float (4 B), +/// 3 = rgba16float (8 B; base-WebGPU-safe storage + filterable fallback), +/// 4 = rgba8unorm (4 B), +/// 5 = r32uint (4 B; storage write, integer loads only). +fn texture3d_format(code: u8) -> Result { + Ok(match code { + 0 => TextureFormat::R16Float, + 1 => TextureFormat::R32Float, + 2 => TextureFormat::Rg16Float, + 3 => TextureFormat::Rgba16Float, + 4 => TextureFormat::Rgba8Unorm, + 5 => TextureFormat::R32Uint, + _ => { + return Err(error::ProcessingError::InvalidArgument(format!( + "unknown texture3d format code: {code}" + ))); + } + }) +} + +/// Create a zero-initialized 3D texture. Returns the texture id (usable +/// wherever an image id is accepted: shader/material texture params). +/// +/// # Safety +/// - Init has been called; called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_texture3d_create( + width: u32, + height: u32, + depth: u32, + format: u8, +) -> u64 { + error::clear_error(); + error::check(|| { + let format = texture3d_format(format)?; + texture3d_create( + Extent3d { + width, + height, + depth_or_array_layers: depth, + }, + format, + ) + }) + .map(|entity| entity.to_bits()) + .unwrap_or(0) +} + +/// Bytes per texel for a format code (for sizing upload/readback buffers). +#[unsafe(no_mangle)] +pub extern "C" fn processing_texture3d_texel_size(format: u8) -> u32 { + error::clear_error(); + error::check(|| { + let format = texture3d_format(format)?; + image::pixel_size(format).map(|s| s as u32) + }) + .unwrap_or(0) +} + +/// Upload a full volume of tightly-packed texel bytes (x fastest, then y, +/// then z; `data_len` must equal width*height*depth*texel_size). +/// +/// # Safety +/// - `data` is valid for `data_len` bytes. +/// - Init has been called; called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_texture3d_write( + texture_id: u64, + data: *const u8, + data_len: usize, +) { + error::clear_error(); + let data = unsafe { std::slice::from_raw_parts(data, data_len) }; + error::check(|| texture3d_write(Entity::from_bits(texture_id), data.to_vec())); +} + +/// Read the full volume back as tightly-packed texel bytes. `out_len` must +/// equal width*height*depth*texel_size. +/// +/// # Safety +/// - `out` is valid for `out_len` writable bytes. +/// - Init has been called; called from the same thread as init. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_texture3d_readback( + texture_id: u64, + out: *mut u8, + out_len: usize, +) { + error::clear_error(); + error::check(|| { + let bytes = texture3d_readback(Entity::from_bits(texture_id))?; + if bytes.len() != out_len { + let msg = format!( + "texture3d readback: buffer size mismatch: expected {}, got {}", + bytes.len(), + out_len + ); + return Err(error::ProcessingError::InvalidArgument(msg)); + } + unsafe { + std::slice::from_raw_parts_mut(out, out_len).copy_from_slice(&bytes); + } + Ok(()) + }); +} + +/// Destroy a 3D texture (same entity space as images). +/// +/// # Safety +/// - Init has been called; called from the same thread as init. +#[unsafe(no_mangle)] +pub extern "C" fn processing_texture3d_destroy(texture_id: u64) { + error::clear_error(); + error::check(|| image_destroy(Entity::from_bits(texture_id))); +} diff --git a/crates/processing_ffi/src/video.rs b/crates/processing_ffi/src/video.rs new file mode 100644 index 00000000..ca123ff1 --- /dev/null +++ b/crates/processing_ffi/src/video.rs @@ -0,0 +1,499 @@ +//! Video playback and offline recording. + +use std::ffi::c_char; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bevy::color::{ColorToPacked, Srgba}; +use bevy::prelude::Entity; +use bevy::render::render_resource::TextureFormat; +use processing::prelude::{ + error::ProcessingError, graphics, graphics_readback_enqueue, graphics_readback_fetch, + graphics_readback_pending, image, +}; +use processing_video::{ + PlaybackMode, RecorderCodec, RecorderPixelFormat, VideoRecorder, VideoRecorderConfig, + video_create, video_destroy, video_image, video_is_loaded, video_load, video_position, + video_resolution, video_seek, video_set_mode, video_set_paused, video_set_speed, +}; + +use crate::{cstr_to_str, error}; + +// ── Playback ──────────────────────────────────────────────────────────────── + +/// Load a video file and create a playback entity for it. The path resolves +/// like other assets (relative to the configured asset root). Decoding starts +/// in the background; poll `processing_video_is_loaded` before querying +/// resolution or drawing frames. Returns the video entity, or 0 on error. +/// +/// SAFETY: +/// - Init has been called. +/// - path is a valid, NUL-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_video_create(path: *const c_char) -> u64 { + error::clear_error(); + error::check(|| { + let path = unsafe { cstr_to_str(path) }?; + let handle = video_load(path)?; + let entity = video_create(handle)?; + Ok(entity.to_bits()) + }) + .unwrap_or(0) +} + +/// Whether the video's first frame has been decoded and its output exists. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_is_loaded(video_id: u64) -> bool { + error::clear_error(); + error::check(|| video_is_loaded(Entity::from_bits(video_id))).unwrap_or(false) +} + +/// Image entity backed by the video's output texture, creating it on first +/// call. The image tracks playback; draw it like any other image. Returns 0 +/// on error (including the video not yet being loaded). +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_image(video_id: u64) -> u64 { + error::clear_error(); + error::check(|| Ok(video_image(Entity::from_bits(video_id))?.to_bits())).unwrap_or(0) +} + +/// Video frame width in pixels, 0 until loaded. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_width(video_id: u64) -> u32 { + error::clear_error(); + error::check(|| Ok(video_resolution(Entity::from_bits(video_id))?.0)).unwrap_or(0) +} + +/// Video frame height in pixels, 0 until loaded. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_height(video_id: u64) -> u32 { + error::clear_error(); + error::check(|| Ok(video_resolution(Entity::from_bits(video_id))?.1)).unwrap_or(0) +} + +/// Current playback position in seconds. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_position(video_id: u64) -> f64 { + error::clear_error(); + error::check(|| video_position(Entity::from_bits(video_id))).unwrap_or(0.0) +} + +/// Seek to a position in seconds. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_seek(video_id: u64, seconds: f64) { + error::clear_error(); + error::check(|| video_seek(Entity::from_bits(video_id), seconds)); +} + +/// Pause or resume playback. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_set_paused(video_id: u64, paused: bool) { + error::clear_error(); + error::check(|| video_set_paused(Entity::from_bits(video_id), paused)); +} + +/// Set the playback rate (1.0 = normal speed). +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_set_speed(video_id: u64, speed: f32) { + error::clear_error(); + error::check(|| video_set_speed(Entity::from_bits(video_id), speed)); +} + +/// Choose whether playback loops or stops at the end. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_set_loop(video_id: u64, looping: bool) { + error::clear_error(); + error::check(|| { + let mode = if looping { + PlaybackMode::Loop + } else { + PlaybackMode::Once + }; + video_set_mode(Entity::from_bits(video_id), mode) + }); +} + +/// Destroy the video player and its linked image. +#[unsafe(no_mangle)] +pub extern "C" fn processing_video_destroy(video_id: u64) { + error::clear_error(); + error::check(|| video_destroy(Entity::from_bits(video_id))); +} + +// ── Recording ─────────────────────────────────────────────────────────────── + +const X264_PRESETS: [&str; 10] = [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + "placebo", +]; + +enum RecState { + /// The recorder is created on the first captured frame so its dimensions + /// match the actual readback. + Pending { + path: String, + fps: f64, + crf: Option, + preset: Option, + codec: RecorderCodec, + bitrate: Option, + bframes: Option, + }, + Active(VideoRecorder), + /// GPU-resident path (macOS + VideoToolbox codecs): frames are blitted + /// into IOSurface-backed pixel buffers and encoded by the media engine; + /// no readback, no CPU pixel work. See `video_macos`. + #[cfg(target_os = "macos")] + Gpu(crate::video_macos::GpuRecorder), +} + +static RECORDER: Mutex> = Mutex::new(None); + +/// The graphics whose frames the active recording captures — set on the +/// first capture so the end/exit paths can drain in-flight async readbacks. +static ACTIVE_GRAPHICS: AtomicU64 = AtomicU64::new(0); + +fn record_err(e: impl std::fmt::Display) -> ProcessingError { + ProcessingError::InvalidArgument(format!("video record: {e}")) +} + +/// Convert a raw readback into encoder input. 8-bit sRGB surfaces (RGBA or +/// BGRA) pass through untouched — swscale converts to the encoder's 4:2:0 +/// layout far faster than a per-pixel CPU loop; anything else takes the slow +/// LinearRgba conversion path. +fn recorder_pixels( + raw: graphics::ReadbackData, +) -> Result<(Vec, RecorderPixelFormat), ProcessingError> { + match raw.format { + TextureFormat::Rgba8UnormSrgb | TextureFormat::Rgba8Unorm => { + Ok((raw.bytes, RecorderPixelFormat::Rgba8)) + } + TextureFormat::Bgra8UnormSrgb | TextureFormat::Bgra8Unorm => { + Ok((raw.bytes, RecorderPixelFormat::Bgra8)) + } + _ => { + let px = image::pixel_size(raw.format)?; + let pixels = image::bytes_to_pixels( + &raw.bytes, + raw.format, + raw.width, + raw.height, + raw.width as usize * px, + )?; + Ok(( + pixels + .iter() + .flat_map(|pixel| Srgba::from(*pixel).to_u8_array()) + .collect(), + RecorderPixelFormat::Rgba8, + )) + } + } +} + +/// Feed one fetched frame to the recorder, creating it on the first frame so +/// its dimensions and pixel format match the actual readback. +fn feed_frame(state: &mut RecState, raw: graphics::ReadbackData) -> Result<(), ProcessingError> { + let (width, height) = (raw.width, raw.height); + let (pixels, pixel_format) = recorder_pixels(raw)?; + if let RecState::Pending { + path, + fps, + crf, + preset, + codec, + bitrate, + bframes, + } = state + { + let mut config = + VideoRecorderConfig::new(width, height, *fps).with_pixel_format(pixel_format); + config.crf = *crf; + config.preset = preset.clone(); + config.codec = *codec; + config.bitrate = *bitrate; + config.bframes = *bframes; + let recorder = VideoRecorder::new(&*path, config).map_err(record_err)?; + *state = RecState::Active(recorder); + } + let RecState::Active(recorder) = state else { + unreachable!("state was just made Active"); + }; + recorder.record_frame(pixels).map_err(record_err) +} + +/// Blocking-drain every in-flight async readback into the recorder. +fn drain_ring(state: &mut RecState) -> Result<(), ProcessingError> { + let bits = ACTIVE_GRAPHICS.load(Ordering::Relaxed); + if bits == 0 { + return Ok(()); + } + let entity = Entity::from_bits(bits); + while graphics_readback_pending(entity)? > 0 { + match graphics_readback_fetch(entity, true)? { + Some(raw) => feed_frame(state, raw)?, + None => break, + } + } + Ok(()) +} + +/// Start recording captured frames into a video file (container chosen by +/// the path extension). Frame timestamps come from the frame index and +/// `fps`, never wall-clock time. `crf` is 0 (lossless) to 51 (worst); pass a +/// negative value for the encoder default (23). `preset` is an x264 speed +/// preset (`ultrafast` .. `placebo`); pass null or an empty string for +/// `medium`. `codec` selects the encoder: 0 = software x264, 1 = hardware +/// H.264 via VideoToolbox (macOS), 2 = hardware HEVC via VideoToolbox. +/// `crf`/`preset` apply to x264 only; the VideoToolbox encoders are +/// rate-controlled by `bitrate_bps` (bits per second; pass 0 or negative for +/// an automatic choice of ~0.1 bits/pixel/frame). Frames are queued with +/// `processing_record_capture` and the file is finalized by +/// `processing_record_end`. `bframes` caps consecutive B frames; pass a +/// negative value for the encoder default (3 for x264), or 0 to disable them, +/// which removes the periodic quality pulse B frames cause on content the +/// codec predicts badly. +/// +/// SAFETY: +/// - Init has been called. +/// - path is a valid, NUL-terminated C string. +/// - preset is null or a valid, NUL-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_record_begin( + path: *const c_char, + fps: f64, + crf: i32, + preset: *const c_char, + codec: u8, + bitrate_bps: i64, + bframes: i32, +) { + error::clear_error(); + error::check(|| { + let path = unsafe { cstr_to_str(path) }?.to_string(); + let preset = if preset.is_null() { + None + } else { + match unsafe { cstr_to_str(preset) }? { + "" => None, + p => Some(p.to_string()), + } + }; + if let Some(ref preset) = preset + && !X264_PRESETS.contains(&preset.as_str()) + { + return Err(ProcessingError::InvalidArgument(format!( + "unknown preset {preset:?}; expected one of {X264_PRESETS:?}" + ))); + } + if !(fps.is_finite() && fps > 0.0 && fps <= 1_000_000.0) { + return Err(ProcessingError::InvalidArgument(format!( + "fps must be a positive number no greater than 1000000, got {fps}" + ))); + } + let crf = match crf { + i32::MIN..0 => None, + 0..=51 => Some(crf as u8), + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "crf must be between 0 (lossless) and 51 (worst quality), got {crf}" + ))); + } + }; + let codec = match codec { + 0 => RecorderCodec::X264, + 1 => RecorderCodec::H264VideoToolbox, + 2 => RecorderCodec::HevcVideoToolbox, + other => { + return Err(ProcessingError::InvalidArgument(format!( + "unknown codec {other}; expected 0 (x264), 1 (h264_videotoolbox), \ + or 2 (hevc_videotoolbox)" + ))); + } + }; + let bitrate = (bitrate_bps > 0).then_some(bitrate_bps as u64); + let bframes = match bframes { + i32::MIN..0 => None, + 0..=16 => Some(bframes as u32), + _ => { + return Err(ProcessingError::InvalidArgument(format!( + "bframes must be between 0 (none) and 16, got {bframes}" + ))); + } + }; + let mut slot = RECORDER.lock().unwrap(); + if slot.is_some() { + return Err(ProcessingError::InvalidArgument( + "already recording; call processing_record_end first".to_string(), + )); + } + *slot = Some(RecState::Pending { + path, + fps, + crf, + preset, + codec, + bitrate, + bframes, + }); + Ok(()) + }); +} + +/// Capture the graphics canvas as one video frame. A no-op when not +/// recording. The copy is submitted asynchronously into a bounded ring so +/// the render loop overlaps the next frame with this frame's readback; +/// completed frames are drained to the encoder in order. The recorder is +/// created on the first completed frame, sized to the readback; a failed +/// capture stops the recording and reports the error. +#[unsafe(no_mangle)] +pub extern "C" fn processing_record_capture(graphics_id: u64) { + error::clear_error(); + error::check(|| { + let mut slot = RECORDER.lock().unwrap(); + let Some(state) = slot.as_mut() else { + return Ok(()); + }; + ACTIVE_GRAPHICS.store(graphics_id, Ordering::Relaxed); + let entity = Entity::from_bits(graphics_id); + // macOS + a VideoToolbox codec: promote to the GPU-resident path on + // the first capture. `PROCESSING_RECORD_CPU=1` forces the readback + + // ffmpeg path (A/B comparison, escape hatch). + #[cfg(target_os = "macos")] + if let RecState::Pending { + path, + fps, + codec, + bitrate, + bframes, + .. + } = state + && std::env::var_os("PROCESSING_RECORD_CPU").is_none() + { + let avf_codec = match codec { + RecorderCodec::H264VideoToolbox => Some(processing_video::macos::AvfCodec::H264), + RecorderCodec::HevcVideoToolbox => Some(processing_video::macos::AvfCodec::Hevc), + RecorderCodec::X264 => None, + }; + if let Some(avf_codec) = avf_codec { + *state = RecState::Gpu(crate::video_macos::GpuRecorder::new( + std::mem::take(path), + *fps, + avf_codec, + *bitrate, + *bframes, + )); + } + } + #[cfg(target_os = "macos")] + if let RecState::Gpu(gpu) = state { + let result = gpu.capture(entity); + if result.is_err() { + *slot = None; + ACTIVE_GRAPHICS.store(0, Ordering::Relaxed); + } + return result; + } + let result = (|| { + let timing = std::env::var_os("PROCESSING_RECORD_TIMING").is_some(); + let t0 = std::time::Instant::now(); + graphics_readback_enqueue(entity)?; + let t1 = std::time::Instant::now(); + let mut fetched = 0u32; + while let Some(raw) = graphics_readback_fetch(entity, false)? { + feed_frame(state, raw)?; + fetched += 1; + } + let t2 = std::time::Instant::now(); + // Ring at capacity: block on the oldest copy so the ring never + // overflows — render stays paced by readback+encode throughput. + let mut blocked = false; + if graphics_readback_pending(entity)? >= graphics::READBACK_RING_DEPTH { + if let Some(raw) = graphics_readback_fetch(entity, true)? { + feed_frame(state, raw)?; + blocked = true; + } + } + if timing { + eprintln!( + "capture: enqueue={:?} fetch={:?}({fetched}) block={:?}({blocked})", + t1 - t0, + t2 - t1, + t2.elapsed(), + ); + } + Ok(()) + })(); + if result.is_err() { + *slot = None; + ACTIVE_GRAPHICS.store(0, Ordering::Relaxed); + } + result + }); +} + +/// Whether a recording is in progress. +#[unsafe(no_mangle)] +pub extern "C" fn processing_record_active() -> bool { + error::clear_error(); + RECORDER.lock().unwrap().is_some() +} + +/// Finish the recording, finalize the file, and return the number of frames +/// encoded (0 on error). +#[unsafe(no_mangle)] +pub extern "C" fn processing_record_end() -> u64 { + error::clear_error(); + error::check(|| { + let taken = RECORDER.lock().unwrap().take(); + match taken { + None => Err(ProcessingError::InvalidArgument( + "not recording; call processing_record_begin first".to_string(), + )), + Some(mut state) => { + let drained = drain_ring(&mut state); + let _entity_bits = ACTIVE_GRAPHICS.swap(0, Ordering::Relaxed); + drained?; + match state { + RecState::Pending { .. } => Err(ProcessingError::InvalidArgument( + "no frames were captured between processing_record_begin and \ + processing_record_end" + .to_string(), + )), + RecState::Active(recorder) => recorder.finish().map_err(record_err), + #[cfg(target_os = "macos")] + RecState::Gpu(gpu) => gpu.finish(_entity_bits), + } + } + } + }) + .unwrap_or(0) +} + +/// Closing the window mid-recording still leaves a playable file. +pub(crate) fn finish_on_exit() { + if let Ok(mut slot) = RECORDER.lock() + && let Some(mut state) = slot.take() + { + let _ = drain_ring(&mut state); + match state { + RecState::Active(recorder) => { + let _ = recorder.finish(); + } + #[cfg(target_os = "macos")] + RecState::Gpu(gpu) => { + let _ = gpu.finish(ACTIVE_GRAPHICS.load(Ordering::Relaxed)); + } + _ => {} + } + } + ACTIVE_GRAPHICS.store(0, Ordering::Relaxed); +} diff --git a/crates/processing_ffi/src/video_macos.rs b/crates/processing_ffi/src/video_macos.rs new file mode 100644 index 00000000..b2d22aee --- /dev/null +++ b/crates/processing_ffi/src/video_macos.rs @@ -0,0 +1,134 @@ +//! GPU-resident recording glue (macOS only). +//! +//! Pairs `processing_render`'s IOSurface capture (wgpu blit, GPU completion +//! callback) with `processing_video`'s AVAssetWriter recorder (hardware +//! encode + mux). Selected automatically by `processing_record_capture` when +//! a VideoToolbox codec is requested; set `PROCESSING_RECORD_CPU=1` to force +//! the readback + ffmpeg path instead. + +use bevy::prelude::Entity; +use processing::prelude::{ + error::ProcessingError, graphics_capture_into_iosurface, graphics_capture_reset, + graphics_device_poll, graphics_surface_size, +}; +use processing_video::macos::{AvfCodec, AvfRecorder, AvfRecorderConfig}; + +fn record_err(e: impl std::fmt::Display) -> ProcessingError { + ProcessingError::InvalidArgument(format!("video record: {e}")) +} + +/// One in-progress GPU recording. The AVF recorder is created on the first +/// captured frame so its dimensions match the graphics being recorded. +pub struct GpuRecorder { + path: String, + fps: f64, + codec: AvfCodec, + bitrate: Option, + bframes: Option, + recorder: Option, +} + +impl GpuRecorder { + pub fn new( + path: String, + fps: f64, + codec: AvfCodec, + bitrate: Option, + bframes: Option, + ) -> Self { + Self { + path, + fps, + codec, + bitrate, + bframes, + recorder: None, + } + } + + /// Capture the current canvas as one frame: check a pixel buffer out of + /// the recorder's pool, blit into its IOSurface on the GPU, and hand the + /// buffer to the encoder when the GPU signals completion. Blocks only + /// when `MAX_IN_FLIGHT` frames are still in the pipeline. + pub fn capture(&mut self, entity: Entity) -> Result<(), ProcessingError> { + let timing = std::env::var_os("PROCESSING_RECORD_TIMING").is_some(); + let t0 = std::time::Instant::now(); + let (width, height) = graphics_surface_size(entity)?; + let recorder = match &mut self.recorder { + Some(r) => { + if r.width() != width || r.height() != height { + return Err(record_err(format!( + "canvas resized mid-recording ({}x{} -> {width}x{height})", + r.width(), + r.height(), + ))); + } + r + } + None => { + let config = AvfRecorderConfig { + width, + height, + fps: self.fps, + codec: self.codec, + bitrate: self.bitrate, + bframes: self.bframes, + }; + self.recorder + .insert(AvfRecorder::new(&self.path, config).map_err(record_err)?) + } + }; + + let frame = recorder + .acquire_frame(&mut || { + let _ = graphics_device_poll(); + }) + .map_err(record_err)?; + let t1 = std::time::Instant::now(); + let ptr = frame.iosurface_ptr(); + let id = frame.iosurface_id(); + let completer = recorder.completer(); + graphics_capture_into_iosurface( + entity, + ptr, + id, + Box::new(move || completer.complete(frame)), + )?; + if timing { + eprintln!( + "capture(gpu): acquire={:?} blit-submit={:?}", + t1 - t0, + t1.elapsed(), + ); + } + Ok(()) + } + + /// Finalize the file and return the number of frames encoded. `entity` is + /// the recorded graphics (0 bits if no frame was ever captured), used to + /// drop the render side's texture cache. + pub fn finish(self, entity_bits: u64) -> Result { + if entity_bits != 0 { + // Best-effort: the graphics may already be gone at exit. + let _ = graphics_capture_reset(Entity::from_bits(entity_bits)); + } + match self.recorder { + Some(recorder) => { + let frames = recorder + .finish(&mut || { + let _ = graphics_device_poll(); + }) + .map_err(record_err)?; + if frames == 0 { + return Err(record_err("no frames were captured")); + } + Ok(frames) + } + None => Err(ProcessingError::InvalidArgument( + "no frames were captured between processing_record_begin and \ + processing_record_end" + .to_string(), + )), + } + } +} diff --git a/crates/processing_glfw/src/lib.rs b/crates/processing_glfw/src/lib.rs index 9bf6a2f2..18856805 100644 --- a/crates/processing_glfw/src/lib.rs +++ b/crates/processing_glfw/src/lib.rs @@ -13,7 +13,7 @@ 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_window_resize, + input_set_mouse_button, input_set_mouse_move, input_set_scroll, }; use processing_render::surface::{MonitorWorkarea, WindowControls}; @@ -410,9 +410,10 @@ impl ManagedWindow { return false; } - // Apply the coalesced resize once, the WindowResized message and Window change are processed this poll. + // Apply the coalesced resize once, so the WindowResized message and the + // Window change are both processed this poll. `surface_resize` writes + // the message itself -- see the note in `processing_render::surface`. 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(); } diff --git a/crates/processing_input/src/lib.rs b/crates/processing_input/src/lib.rs index 4ee0bfd4..3d40e1ad 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, WindowResized}; +use bevy::window::CursorMoved; use processing_core::app_mut; use processing_core::error; @@ -366,14 +366,3 @@ 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_input/src/state.rs b/crates/processing_input/src/state.rs index e63995aa..d64585f7 100644 --- a/crates/processing_input/src/state.rs +++ b/crates/processing_input/src/state.rs @@ -60,7 +60,9 @@ pub fn track_last_key(mut reader: MessageReader, mut last: ResMut for event in reader.read() { match event.state { ButtonState::Pressed => { - last.code = Some(event.key_code); + if !matches!(event.key_code, KeyCode::Unidentified(_)) { + last.code = Some(event.key_code); + } last.character = event.text.as_ref().and_then(|t| t.chars().next()); last.just_pressed = true; } diff --git a/crates/processing_physics/Cargo.toml b/crates/processing_physics/Cargo.toml new file mode 100644 index 00000000..67e1620f --- /dev/null +++ b/crates/processing_physics/Cargo.toml @@ -0,0 +1,19 @@ +[package] +name = "processing_physics" +version = "0.0.1" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +avian3d = { path = "/Users/char/src/github.com/avianphysics/avian/crates/avian3d", default-features = false, features = [ + "3d", + "parry-f32", + "parallel", + "collider-from-mesh", +] } +bevy = { workspace = true } +processing_core = { workspace = true } +processing_render = { workspace = true } +thiserror = "2" diff --git a/crates/processing_physics/src/lib.rs b/crates/processing_physics/src/lib.rs new file mode 100644 index 00000000..05be2017 --- /dev/null +++ b/crates/processing_physics/src/lib.rs @@ -0,0 +1,480 @@ +//! Physics integration for processing, wrapping the avian3d physics engine. +//! +//! Bodies are pure simulation entities: they carry no render components. A +//! sketch draws them itself by reading transforms back (typically in bulk into +//! a particle system's position/rotation buffers for instanced drawing). +//! +//! Stepping is frame-locked by default: `Time` is paused and advanced +//! by exactly `1/hz` every app update, so a sketch rendered frame-by-frame is +//! deterministic regardless of wall-clock jitter (same policy as the paused +//! animation-pool puppets). `set_realtime` opts back into wall-clock stepping. + +use std::time::Duration; + +use avian3d::prelude::*; +use bevy::prelude::*; +use processing_core::app_mut; +use processing_core::error::Result; + +pub use avian3d; + +use crate::error::PhysicsError; + +pub mod error { + use processing_core::error::ProcessingError; + use thiserror::Error; + + #[derive(Error, Debug)] + pub enum PhysicsError { + #[error("unknown rigid body kind {0} (expected 0=static, 1=dynamic, 2=kinematic)")] + UnknownBodyKind(u32), + #[error("unknown collider shape {0} (expected 0=box, 1=sphere, 2=capsule)")] + UnknownShape(u32), + #[error( + "unknown mesh collider mode {0} (expected 0=trimesh, 1=convex hull, 2=convex decomposition)" + )] + UnknownMeshMode(u32), + #[error("collider construction from mesh failed (empty or non-triangle geometry?)")] + MeshColliderFailed, + #[error("entity is not a physics body")] + NotABody, + } + + impl From for ProcessingError { + fn from(err: PhysicsError) -> Self { + ProcessingError::InvalidArgument(err.to_string()) + } + } +} + +/// How the physics clock advances each app update. +#[derive(Resource, Debug, Clone, Copy)] +pub struct PhysicsFrameClock { + /// Fixed timestep frequency; each rendered frame advances the simulation + /// by exactly `1/hz` seconds. + pub hz: f64, + /// When false, `Time` is unpaused and follows the wall clock. + pub frame_locked: bool, + /// When true, the frame clock stops advancing (bodies freeze). + pub paused: bool, + /// One step is owed. Set by [`step`] at the start of a Processing frame + /// and consumed by the first app update that follows, so extra updates + /// pumped by flushes/offscreen graphics within the same frame don't + /// advance the simulation again. + pub pending: bool, +} + +impl Default for PhysicsFrameClock { + fn default() -> Self { + Self { + hz: 60.0, + frame_locked: true, + paused: false, + pending: false, + } + } +} + +/// Marker for entities created through the processing physics API. +#[derive(Component)] +pub struct PhysicsBody; + +pub struct ProcessingPhysicsPlugin; + +impl Plugin for ProcessingPhysicsPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(PhysicsPlugins::new(PostUpdate)); + app.init_resource::(); + app.add_systems(Startup, pause_physics_clock); + app.add_systems( + PostUpdate, + advance_frame_clock.before(PhysicsSystems::First), + ); + } +} + +fn pause_physics_clock(mut time: ResMut>) { + time.pause(); +} + +/// Frame-locked stepping: while `Time` is paused, avian's schedule +/// runner uses whatever delta a manual `advance_by` set, so advancing by +/// exactly `1/hz` here steps the simulation deterministically per frame. +/// Gated on `pending` — the host marks each Processing frame with [`step`], +/// and every flush-pumped app update in between is a no-op. +fn advance_frame_clock(mut clock: ResMut, mut time: ResMut>) { + if clock.frame_locked { + if !time.is_paused() { + time.pause(); + } + if clock.pending && !clock.paused { + time.advance_by(Duration::from_secs_f64(1.0 / clock.hz)); + } + clock.pending = false; + } else if time.is_paused() && !clock.paused { + time.unpause(); + } else if clock.paused && !time.is_paused() { + time.pause(); + } +} + +/// Mark the start of a Processing frame: the next app update advances the +/// simulation by one fixed step (frame-locked mode only; a wall-clock world +/// steps on every update regardless). +pub fn step() -> Result<()> { + app_mut(|app| { + app.world_mut() + .resource_mut::() + .pending = true; + Ok(()) + }) +} + +// ── World configuration ───────────────────────────────────────────────────── + +pub fn set_gravity(gravity: Vec3) -> Result<()> { + app_mut(|app| { + app.world_mut().insert_resource(Gravity(gravity)); + Ok(()) + }) +} + +pub fn set_timestep_hz(hz: f64) -> Result<()> { + app_mut(|app| { + let mut clock = app.world_mut().resource_mut::(); + clock.hz = hz.max(1.0); + clock.frame_locked = true; + Ok(()) + }) +} + +pub fn set_realtime() -> Result<()> { + app_mut(|app| { + app.world_mut() + .resource_mut::() + .frame_locked = false; + Ok(()) + }) +} + +pub fn set_paused(paused: bool) -> Result<()> { + app_mut(|app| { + app.world_mut().resource_mut::().paused = paused; + Ok(()) + }) +} + +pub fn set_substeps(count: u32) -> Result<()> { + app_mut(|app| { + app.world_mut() + .insert_resource(SubstepCount(count.max(1))); + Ok(()) + }) +} + +// ── Bodies ────────────────────────────────────────────────────────────────── + +fn body_kind(kind: u32) -> std::result::Result { + match kind { + 0 => Ok(RigidBody::Static), + 1 => Ok(RigidBody::Dynamic), + 2 => Ok(RigidBody::Kinematic), + other => Err(PhysicsError::UnknownBodyKind(other)), + } +} + +/// Shape params: box = full extents (x, y, z); sphere = (radius, _, _); +/// capsule = (radius, length, _) where length is the cylindrical section. +fn collider_shape(shape: u32, p: Vec3) -> std::result::Result { + match shape { + 0 => Ok(Collider::cuboid(p.x, p.y, p.z)), + 1 => Ok(Collider::sphere(p.x)), + 2 => Ok(Collider::capsule(p.x, p.y)), + other => Err(PhysicsError::UnknownShape(other)), + } +} + +pub fn body_create( + kind: u32, + shape: u32, + params: Vec3, + position: Vec3, + rotation: Quat, +) -> Result { + app_mut(|app| { + let body = body_kind(kind)?; + let collider = collider_shape(shape, params)?; + Ok(app + .world_mut() + .spawn(( + PhysicsBody, + body, + collider, + Transform::from_translation(position).with_rotation(rotation), + )) + .id()) + }) +} + +/// Create a rigid body whose collider is built from an existing geometry's +/// CPU mesh (the same entities the geometry/glTF APIs hand out). +/// +/// `mode`: 0 = trimesh (exact, best for static scenery), 1 = convex hull +/// (fast, best for dynamic bodies), 2 = convex decomposition (accurate AND +/// dynamic-friendly, expensive to build). +pub fn body_create_from_geometry( + geometry: Entity, + kind: u32, + mode: u32, + position: Vec3, + rotation: Quat, +) -> Result { + app_mut(|app| { + let body = body_kind(kind)?; + let world = app.world_mut(); + let handle = world + .get::(geometry) + .ok_or(processing_core::error::ProcessingError::GeometryNotFound)? + .handle + .clone(); + let meshes = world.resource::>(); + let mesh = meshes.get(&handle).ok_or(processing_core::error::ProcessingError::GeometryNotFound)?; + let collider = match mode { + 0 => Collider::trimesh_from_mesh(mesh), + 1 => Collider::convex_hull_from_mesh(mesh), + 2 => Collider::convex_decomposition_from_mesh(mesh), + other => return Err(PhysicsError::UnknownMeshMode(other).into()), + } + // Engine meshes (glTF in particular) are often non-indexed triangle + // soups, which avian's *_from_mesh helpers reject; sequential indices + // are exact for a soup, so rebuild from raw positions. + .or_else(|| collider_from_triangle_soup(mesh, mode)) + .ok_or_else(|| { + processing_core::error::ProcessingError::InvalidArgument(format!( + "collider construction from mesh failed (position attr: {:?}, indices: {:?}, topology: {:?})", + mesh.attribute(Mesh::ATTRIBUTE_POSITION).map(|a| { + format!( + "{} verts, float32x3: {}", + a.len(), + matches!(a, bevy::mesh::VertexAttributeValues::Float32x3(_)) + ) + }), + mesh.indices().map(|i| i.len()), + mesh.primitive_topology(), + )) + })?; + Ok(world + .spawn(( + PhysicsBody, + body, + collider, + Transform::from_translation(position).with_rotation(rotation), + )) + .id()) + }) +} + +/// Collider from a non-indexed TriangleList mesh: consecutive vertex triples +/// are the triangles, so sequential indices reconstruct the topology exactly. +fn collider_from_triangle_soup(mesh: &Mesh, mode: u32) -> Option { + use bevy::mesh::{PrimitiveTopology, VertexAttributeValues}; + if mesh.primitive_topology() != PrimitiveTopology::TriangleList || mesh.indices().is_some() { + return None; + } + let VertexAttributeValues::Float32x3(positions) = mesh.attribute(Mesh::ATTRIBUTE_POSITION)? + else { + return None; + }; + let vertices: Vec = positions.iter().map(|p| Vec3::from_array(*p)).collect(); + if vertices.len() < 3 { + return None; + } + let indices: Vec<[u32; 3]> = (0..vertices.len() as u32 / 3) + .map(|t| [t * 3, t * 3 + 1, t * 3 + 2]) + .collect(); + match mode { + 0 => Some(Collider::trimesh(vertices, indices)), + 1 => Collider::convex_hull(vertices), + 2 => Some(Collider::convex_decomposition(vertices, indices)), + _ => None, + } +} + +pub fn body_destroy(entity: Entity) -> Result<()> { + app_mut(|app| { + app.world_mut().entity_mut(entity).despawn(); + Ok(()) + }) +} + +/// Teleport. Writes both `Transform` and avian's `Position`/`Rotation` so the +/// change lands regardless of where in the frame it happens. +pub fn body_set_transform(entity: Entity, position: Vec3, rotation: Quat) -> Result<()> { + app_mut(|app| { + let mut e = app.world_mut().entity_mut(entity); + let mut transform = e.get_mut::().ok_or(PhysicsError::NotABody)?; + transform.translation = position; + transform.rotation = rotation; + if let Some(mut p) = e.get_mut::() { + p.0 = position; + } + if let Some(mut r) = e.get_mut::() { + r.0 = rotation; + } + Ok(()) + }) +} + +/// Position-only teleport: keeps the current rotation and velocities. +pub fn body_set_position(entity: Entity, position: Vec3) -> Result<()> { + app_mut(|app| { + let mut e = app.world_mut().entity_mut(entity); + let mut transform = e.get_mut::().ok_or(PhysicsError::NotABody)?; + transform.translation = position; + if let Some(mut p) = e.get_mut::() { + p.0 = position; + } + Ok(()) + }) +} + +pub fn body_set_linear_velocity(entity: Entity, velocity: Vec3) -> Result<()> { + app_mut(|app| { + let mut e = app.world_mut().entity_mut(entity); + let mut v = e + .get_mut::() + .ok_or(PhysicsError::NotABody)?; + v.0 = velocity; + Ok(()) + }) +} + +pub fn body_set_angular_velocity(entity: Entity, velocity: Vec3) -> Result<()> { + app_mut(|app| { + let mut e = app.world_mut().entity_mut(entity); + let mut v = e + .get_mut::() + .ok_or(PhysicsError::NotABody)?; + v.0 = velocity; + Ok(()) + }) +} + +pub fn body_apply_impulse(entity: Entity, impulse: Vec3) -> Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(apply_impulse_system, (entity, impulse)) + .unwrap() + }) +} + +fn apply_impulse_system( + In((entity, impulse)): In<(Entity, Vec3)>, + mut bodies: Query, +) -> Result<()> { + let mut forces = bodies.get_mut(entity).map_err(|_| PhysicsError::NotABody)?; + forces.apply_linear_impulse(impulse); + Ok(()) +} + +pub fn body_apply_angular_impulse(entity: Entity, impulse: Vec3) -> Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(apply_angular_impulse_system, (entity, impulse)) + .unwrap() + }) +} + +fn apply_angular_impulse_system( + In((entity, impulse)): In<(Entity, Vec3)>, + mut bodies: Query, +) -> Result<()> { + let mut forces = bodies.get_mut(entity).map_err(|_| PhysicsError::NotABody)?; + forces.apply_angular_impulse(impulse); + Ok(()) +} + +pub fn body_set_friction(entity: Entity, friction: f32) -> Result<()> { + app_mut(|app| { + app.world_mut() + .entity_mut(entity) + .insert(Friction::new(friction)); + Ok(()) + }) +} + +pub fn body_set_restitution(entity: Entity, restitution: f32) -> Result<()> { + app_mut(|app| { + app.world_mut() + .entity_mut(entity) + .insert(Restitution::new(restitution)); + Ok(()) + }) +} + +pub fn body_set_density(entity: Entity, density: f32) -> Result<()> { + app_mut(|app| { + app.world_mut() + .entity_mut(entity) + .insert(ColliderDensity(density)); + Ok(()) + }) +} + +// ── Readback ──────────────────────────────────────────────────────────────── + +/// Position (3) + rotation quaternion xyzw (4) for one body. +pub fn body_get_transform(entity: Entity) -> Result<[f32; 7]> { + app_mut(|app| { + let e = app.world().entity(entity); + let (pos, rot) = read_pose(&e).ok_or(PhysicsError::NotABody)?; + Ok([pos.x, pos.y, pos.z, rot.x, rot.y, rot.z, rot.w]) + }) +} + +pub fn body_get_linear_velocity(entity: Entity) -> Result<[f32; 3]> { + app_mut(|app| { + let v = app + .world() + .entity(entity) + .get::() + .ok_or(PhysicsError::NotABody)? + .0; + Ok([v.x, v.y, v.z]) + }) +} + +fn read_pose(e: &EntityRef) -> Option<(Vec3, Quat)> { + // Prefer avian's own components (updated during the step); fall back to + // Transform for bodies that haven't simulated yet. + match (e.get::(), e.get::()) { + (Some(p), Some(r)) => Some((p.0, r.0)), + _ => e.get::().map(|t| (t.translation, t.rotation)), + } +} + +/// Bulk pose readback: writes 7 floats (pos xyz, quat xyzw) per body into +/// `out`, which must hold `ids.len() * 7` floats. Bodies that no longer exist +/// write identity poses. Returns the number of bodies read. +pub fn read_transforms(ids: &[u64], out: &mut [f32]) -> Result { + assert!(out.len() >= ids.len() * 7); + app_mut(|app| { + let world = app.world(); + let mut read = 0; + for (i, &id) in ids.iter().enumerate() { + let o = &mut out[i * 7..i * 7 + 7]; + let pose = world + .get_entity(Entity::from_bits(id)) + .ok() + .and_then(|e| read_pose(&e)); + match pose { + Some((p, r)) => { + o.copy_from_slice(&[p.x, p.y, p.z, r.x, r.y, r.z, r.w]); + read += 1; + } + None => o.copy_from_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0]), + } + } + Ok(read) + }) +} diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 2eb65a94..91ab255d 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -15,6 +15,7 @@ default = ["wayland", "static-link"] wayland = ["processing/wayland", "processing_glfw/wayland"] static-link = ["processing_glfw/static-link"] x11 = ["processing/x11", "processing_glfw/x11"] +video = ["processing/video", "dep:processing_video"] webcam = ["processing/webcam", "dep:processing_webcam"] cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] @@ -22,6 +23,7 @@ cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] pyo3 = { workspace = true, features = ["experimental-inspect", "multiple-pymethods"] } processing = { workspace = true } processing_render = { workspace = true } +processing_video = { workspace = true, optional = true } processing_webcam = { workspace = true, optional = true } processing_glfw = { workspace = true } bevy = { workspace = true, features = ["file_watcher"] } diff --git a/crates/processing_pyo3/examples/particles_lissajous.py b/crates/processing_pyo3/examples/particles_lissajous.py index 6bfa291c..5bfac1b8 100644 --- a/crates/processing_pyo3/examples/particles_lissajous.py +++ b/crates/processing_pyo3/examples/particles_lissajous.py @@ -1,54 +1,40 @@ 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 +N = 10000 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 +CONNECTION_RADIUS = 3.5 +CONNECTION_RAMP = 6.0 +LINE_ALPHA = 0.5 +MAX_LINES = 2_000_000 +HUE_MIX = 0.0 -p = None # curve points (source) -edges = None # emitted line vertices (drawn) +p = None +edges = None curve = None link = None grid = None -idx = None def setup(): - global p, edges, curve, link, grid, idx + global p, edges, curve, link, grid 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 + edges = p.primitives("lines", capacity=MAX_LINES) 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")) + orbit_camera() def draw(): @@ -56,30 +42,20 @@ def draw(): 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) + p.apply(link, primitives=edges) - blend_mode(ALPHA_OVER) - particles(edges, topology="lines") + particles(edges) run() diff --git a/crates/processing_pyo3/examples/test.py b/crates/processing_pyo3/examples/test.py index 53671439..b9c1791b 100644 --- a/crates/processing_pyo3/examples/test.py +++ b/crates/processing_pyo3/examples/test.py @@ -3,7 +3,6 @@ def setup(): size(200, 200) - background(100, 0, 0) print(width) def draw(): diff --git a/crates/processing_pyo3/examples/video.py b/crates/processing_pyo3/examples/video.py new file mode 100644 index 00000000..56ffd053 --- /dev/null +++ b/crates/processing_pyo3/examples/video.py @@ -0,0 +1,59 @@ +from mewnala import * +from mewnala.math import * + +angle = 0.0 + +def setup(): + global vid, paused + size(600, 600) + mode_3d() + + create_directional_light((1.0, 0.98, 0.95), 1500.0) + + vid = create_video("video/file_example_MP4_640_3MG.mp4") + vid.loop() + paused = False + + +def draw(): + global angle + camera_position(0.0, 100.0, 300.0) + camera_look_at(0.0, 0.0, 0.0) + background(30) + stroke(255) + stroke_weight(2) + + if vid.is_loaded(): + texture(vid) + texture_transform(Affine2.from_scale_angle_translation( + Vec2(0.75, 1.0), 0.0, Vec2(0.125, 0.0) + )) + push_matrix() + rotate_x(0.3) + rotate_y(angle) + box(100.0, 100.0, 100.0) + pop_matrix() + no_texture() + + angle += 0.0009 + + +def key_pressed(): + global paused + if key_code == SPACE: + paused = not paused + if paused: + vid.pause() + else: + vid.play() + elif key_code == RIGHT_ARROW: + vid.seek(vid.position() + 1.0) + elif key_code == LEFT_ARROW: + vid.seek(max(0.0, vid.position() - 1.0)) + elif key_code == UP: + vid.speed(2.0) + elif key_code == DOWN: + vid.speed(1.0) + + +run() diff --git a/crates/processing_pyo3/src/color.rs b/crates/processing_pyo3/src/color.rs index 6aef7072..bcba1b5d 100644 --- a/crates/processing_pyo3/src/color.rs +++ b/crates/processing_pyo3/src/color.rs @@ -124,6 +124,8 @@ fn components(color: &Color) -> [f32; 4] { Color::Lcha(c) => c.to_f32_array(), Color::Oklaba(c) => c.to_f32_array(), Color::Oklcha(c) => c.to_f32_array(), + Color::Okhsla(c) => c.to_f32_array(), + Color::Okhsva(c) => c.to_f32_array(), Color::Xyza(c) => c.to_f32_array(), } } @@ -140,6 +142,8 @@ fn components_no_alpha(color: &Color) -> [f32; 3] { Color::Lcha(c) => c.to_f32_array_no_alpha(), Color::Oklaba(c) => c.to_f32_array_no_alpha(), Color::Oklcha(c) => c.to_f32_array_no_alpha(), + Color::Okhsla(c) => c.to_f32_array_no_alpha(), + Color::Okhsva(c) => c.to_f32_array_no_alpha(), Color::Xyza(c) => c.to_f32_array_no_alpha(), } } diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index e801fad8..b2fb87a0 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -151,6 +151,23 @@ fn composite_apply( graphics_apply_filter(dst, filter).map_err(rt_err) } +/// Read a graphics canvas back as sRGB-encoded RGBA8 bytes plus pixel dimensions. +pub(crate) fn readback_rgba8(entity: Entity) -> PyResult<(Vec, u32, u32)> { + let raw = graphics_readback_raw(entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let rgba = match raw.format { + TextureFormat::Rgba8UnormSrgb => raw.bytes, + _ => { + let pixels = + graphics_readback(entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + pixels + .iter() + .flat_map(|pixel| Srgba::from(*pixel).to_u8_array()) + .collect() + } + }; + Ok((rgba, raw.width, raw.height)) +} + /// 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(); @@ -537,6 +554,12 @@ impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { if let Ok(img) = ob.extract::>() { return Ok(ImageRef { entity: img.entity }); } + #[cfg(feature = "video")] + if let Ok(vid) = ob.extract::>() { + return Ok(ImageRef { + entity: vid.image_entity()?, + }); + } #[cfg(feature = "webcam")] if let Ok(cam) = ob.extract::>() { return Ok(ImageRef { @@ -544,7 +567,7 @@ impl<'a, 'py> FromPyObject<'a, 'py> for ImageRef { }); } Err(pyo3::exceptions::PyTypeError::new_err( - "expected an Image or Webcam", + "expected an Image, Video, or Webcam", )) } } @@ -941,25 +964,11 @@ impl Graphics { } pub fn readback_png(&self) -> PyResult> { - let raw = graphics_readback_raw(self.entity) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - - // png-ify our raw data, for srgb formats we're already good - let rgba_bytes = 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() - } - }; + let (rgba_bytes, width, height) = readback_rgba8(self.entity)?; let mut png_buf: Vec = Vec::new(); { - let mut encoder = png::Encoder::new(&mut png_buf, raw.width, raw.height); + let mut encoder = png::Encoder::new(&mut png_buf, width, height); // todo: infer these from the texture format instead of hardcoding encoder.set_color(png::ColorType::Rgba); encoder.set_depth(png::BitDepth::Eight); @@ -977,20 +986,8 @@ impl Graphics { /// 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) + let (rgba, width, height) = readback_rgba8(self.entity)?; + write_png_file(filename, width, height, &rgba) } /// Read a single pixel as a `Color` (Processing `get`). @@ -1090,6 +1087,15 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn background(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + if let Ok(first) = args.get_item(0) { + if let Ok(img_ref) = first.extract::() { + return graphics_record_command( + self.entity, + DrawCommand::BackgroundImage(img_ref.entity), + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + } let color = extract_color_with_mode( args, &graphics_get_color_mode(self.entity) @@ -2169,6 +2175,21 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn texture(&self, source: ImageRef) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::Texture(source.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn no_texture(&self) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::NoTexture) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn texture_transform(&self, transform: crate::math::PyAffine2) -> PyResult<()> { + graphics_record_command(self.entity, DrawCommand::TextureTransform(transform.0)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn unlit(&self) -> PyResult<()> { graphics_record_command(self.entity, DrawCommand::Unlit) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) @@ -2182,10 +2203,36 @@ impl Graphics { #[pyo3(signature = (particles, geometry = None, topology = None))] pub fn particles( &self, - particles: &crate::particles::Particles, + particles: &Bound<'_, PyAny>, geometry: Option<&Geometry>, topology: Option<&str>, ) -> PyResult<()> { + // A dynamic-topology target knows its own topology and draws its + // internal expanded-vertex field. + if let Ok(prims) = particles.extract::>() { + if geometry.is_some() || topology.is_some() { + return Err(PyValueError::new_err( + "particles(primitives): geometry/topology come from the target", + )); + } + return graphics_record_command( + self.entity, + DrawCommand::Particles { + particles: prims.field, + geometry: None, + topology: prims.topology, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + + let particles = particles + .extract::>() + .map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "particles(): expected a Particles or Primitives object", + ) + })?; let topology = match topology { Some(s) => geometry::Topology::parse(s).ok_or_else(|| { PyValueError::new_err(format!("particles(): unknown topology {s:?}")) diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index f8378bbe..768b0f00 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -23,9 +23,13 @@ pub(crate) mod math; mod midi; mod monitor; pub(crate) mod particles; +#[cfg(feature = "video")] +mod record; pub(crate) mod shader; mod surface; mod time; +#[cfg(feature = "video")] +mod video; #[cfg(feature = "webcam")] mod webcam; @@ -447,6 +451,8 @@ mod mewnala { #[pymodule_export] use super::particles::Particles; #[pymodule_export] + use super::particles::Primitives; + #[pymodule_export] use super::surface::Surface; #[pymodule_init] @@ -566,6 +572,9 @@ mod mewnala { PyColor::xyz(x, y, z, a) } + #[cfg(feature = "video")] + #[pymodule_export] + use super::video::Video; #[cfg(feature = "webcam")] #[pymodule_export] use super::webcam::Webcam; @@ -929,6 +938,7 @@ mod mewnala { } } + sync_globals(module, &globals)?; dispatch_event_callbacks(&locals)?; let should_draw = first_frame @@ -962,12 +972,23 @@ mod mewnala { wg.bind(py).borrow().end_draw()?; } + #[cfg(feature = "video")] + { + let entity = get_graphics(module)? + .ok_or_else(|| PyRuntimeError::new_err("call size() first"))? + .entity; + record::capture(entity)?; + } + update_loop_state(|s| s.redraw_requested = false); } Ok(()) }); + #[cfg(feature = "video")] + record::finish_on_exit(); + // 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); @@ -1168,7 +1189,7 @@ mod mewnala { #[pyo3(pass_module, signature = (particles, geometry = None, topology = None))] fn particles( module: &Bound<'_, PyModule>, - particles: &Bound<'_, super::particles::Particles>, + particles: &Bound<'_, PyAny>, geometry: Option<&Bound<'_, Geometry>>, topology: Option<&str>, ) -> PyResult<()> { @@ -1176,11 +1197,7 @@ mod mewnala { Some(g) => Some(g.extract::>()?), None => None, }; - graphics!(module).particles( - &*particles.extract::>()?, - geometry.as_deref(), - topology, - ) + graphics!(module).particles(particles, geometry.as_deref(), topology) } #[pyfunction(name = "color")] @@ -1201,13 +1218,7 @@ mod mewnala { #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn background(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { - let graphics = graphics!(module); - let first = args.get_item(0)?; - if first.is_instance_of::() { - graphics.background_image(&*first.extract::>()?) - } else { - graphics.background(args) - } + graphics!(module).background(args) } #[pyfunction] @@ -1824,6 +1835,27 @@ mod mewnala { graphics!(module).emissive(args) } + #[pyfunction] + #[pyo3(pass_module)] + fn texture(module: &Bound<'_, PyModule>, source: graphics::ImageRef) -> PyResult<()> { + graphics!(module).texture(source) + } + + #[pyfunction] + #[pyo3(pass_module)] + fn no_texture(module: &Bound<'_, PyModule>) -> PyResult<()> { + graphics!(module).no_texture() + } + + #[pyfunction] + #[pyo3(pass_module)] + fn texture_transform( + module: &Bound<'_, PyModule>, + transform: crate::math::PyAffine2, + ) -> PyResult<()> { + graphics!(module).texture_transform(transform) + } + #[pyfunction] #[pyo3(pass_module)] fn unlit(module: &Bound<'_, PyModule>) -> PyResult<()> { @@ -2227,6 +2259,35 @@ mod mewnala { graphics!(module).draw_tetrahedron(radius) } + #[cfg(feature = "video")] + #[pyfunction] + fn create_video(path: &str) -> PyResult { + video::Video::new(path) + } + + /// Start recording the canvas to a video file (Processing `beginRecord`). + /// Frames are timestamped at `1 / fps` regardless of render time. + /// `crf`: 0 (lossless) to 51, default 23. `preset`: x264 speed/size + /// trade-off, `"ultrafast"` .. `"veryslow"`. + #[cfg(feature = "video")] + #[pyfunction] + #[pyo3(signature = (filename, fps=60.0, crf=None, preset=None))] + fn begin_record( + filename: &str, + fps: f64, + crf: Option, + preset: Option, + ) -> PyResult<()> { + record::begin(filename, fps, crf, preset) + } + + /// Stop recording, finalize the video file, and return the frame count. + #[cfg(feature = "video")] + #[pyfunction] + fn end_record() -> PyResult { + record::end() + } + #[cfg(feature = "webcam")] #[pyfunction] #[pyo3(signature = (width=None, height=None, framerate=None))] diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index 8e033674..e72031da 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -100,6 +100,7 @@ fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { "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)?, + "depth_bias" => material_set_depth_bias(entity, value.extract::()?).map_err(rt)?, "alpha_mode" => { material_set_alpha_mode(entity, value.extract::()?, 0.5).map_err(rt)? } diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs index 5b706f10..f4a88b36 100644 --- a/crates/processing_pyo3/src/particles.rs +++ b/crates/processing_pyo3/src/particles.rs @@ -137,6 +137,39 @@ impl Grid { } } +/// A dynamic-topology target: kernels add points, lines, or triangles to it +/// via the WESL `processing::prims` module, and `particles(target)` draws +/// whatever was added this frame. Created with `p.primitives(...)`. +#[pyclass(unsendable)] +pub struct Primitives { + pub(crate) target: Entity, + pub(crate) field: Entity, + pub(crate) topology: geometry::Topology, + pub(crate) capacity: u32, +} + +#[pymethods] +impl Primitives { + /// Capacity in primitives. + #[getter] + pub fn capacity(&self) -> u32 { + self.capacity + } + + /// Primitives the kernels tried to add this frame, including any dropped + /// because the target was full. Reads back a small GPU stat; call it + /// when you want the number rather than every frame. + pub fn emitted(&self) -> PyResult { + particles_primitives_attempted(self.target).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// True when kernels tried to add more primitives than the capacity this + /// frame (the excess was dropped). + pub fn overflowed(&self) -> PyResult { + Ok(self.emitted()? > self.capacity) + } +} + static FLOCK_COMPUTE: std::sync::Mutex> = std::sync::Mutex::new(None); fn flock_compute() -> PyResult { @@ -782,19 +815,51 @@ impl Particles { particles_reset_indices(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - #[pyo3(signature = (kind, **kwargs))] + /// Creates a dynamic-topology target over this field: kernels applied + /// with `apply(kernel, primitives=target)` add points, lines, or + /// triangles to it via the WESL `processing::prims` module, and + /// `particles(target)` draws whatever was added this frame. `capacity` + /// is counted in primitives. + #[pyo3(signature = (topology, capacity))] + pub fn primitives(&self, topology: &str, capacity: u32) -> PyResult { + let topology = processing_render::geometry::Topology::parse(topology).ok_or_else(|| { + PyValueError::new_err(format!("primitives(): unknown topology {topology:?}")) + })?; + let target = particles_primitives_create(self.entity, topology, capacity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let field = processing_render::particles_primitives_field(target) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Primitives { + target, + field, + topology, + capacity, + }) + } + + #[pyo3(signature = (kind, primitives = None, **kwargs))] pub fn apply( &self, kind: &Bound<'_, PyAny>, + primitives: Option>, kwargs: Option<&Bound<'_, PyDict>>, ) -> PyResult<()> { if let Ok(compute) = kind.extract::>() { if let Some(kwargs) = kwargs { compute.set(Some(kwargs))?; } + if let Some(prims) = primitives { + return particles_primitives_apply(prims.target, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } return particles_apply(self.entity, compute.entity) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); } + if primitives.is_some() { + return Err(PyTypeError::new_err( + "apply(primitives=...) requires a Compute kernel", + )); + } let name: String = kind.extract().map_err(|_| { PyTypeError::new_err( "apply(): first argument must be an operation constant or a Compute", diff --git a/crates/processing_pyo3/src/record.rs b/crates/processing_pyo3/src/record.rs new file mode 100644 index 00000000..918d72be --- /dev/null +++ b/crates/processing_pyo3/src/record.rs @@ -0,0 +1,134 @@ +//! `begin_record` / `end_record`: capture drawn frames into a video file. + +use std::cell::RefCell; + +use bevy::prelude::Entity; +use processing_video::{VideoRecorder, VideoRecorderConfig}; +use pyo3::exceptions::{PyRuntimeError, PyValueError}; +use pyo3::prelude::*; + +const X264_PRESETS: [&str; 10] = [ + "ultrafast", + "superfast", + "veryfast", + "faster", + "fast", + "medium", + "slow", + "slower", + "veryslow", + "placebo", +]; + +enum RecState { + /// The recorder is created on the first captured frame so its dimensions + /// match the actual readback. + Pending { + path: String, + fps: f64, + crf: Option, + preset: Option, + }, + Active(VideoRecorder), +} + +thread_local! { + static RECORDER: RefCell> = const { RefCell::new(None) }; +} + +pub(crate) fn begin(path: &str, fps: f64, crf: Option, preset: Option) -> PyResult<()> { + RECORDER.with(|r| { + let mut slot = r.borrow_mut(); + if slot.is_some() { + return Err(PyRuntimeError::new_err( + "already recording; call end_record() first", + )); + } + if !(fps.is_finite() && fps > 0.0 && fps <= 1_000_000.0) { + return Err(PyValueError::new_err(format!( + "fps must be a positive number no greater than 1000000, got {fps}" + ))); + } + if let Some(crf) = crf + && crf > 51 + { + return Err(PyValueError::new_err(format!( + "crf must be between 0 (lossless) and 51 (worst quality), got {crf}" + ))); + } + if let Some(ref preset) = preset + && !X264_PRESETS.contains(&preset.as_str()) + { + return Err(PyValueError::new_err(format!( + "unknown preset {preset:?}; expected one of {X264_PRESETS:?}" + ))); + } + *slot = Some(RecState::Pending { + path: path.to_string(), + fps, + crf, + preset, + }); + Ok(()) + }) +} + +pub(crate) fn capture(entity: Entity) -> PyResult<()> { + RECORDER.with(|r| { + let mut slot = r.borrow_mut(); + let Some(state) = slot.as_mut() else { + return Ok(()); + }; + let result = (|| { + let (rgba, width, height) = crate::graphics::readback_rgba8(entity)?; + if let RecState::Pending { + path, + fps, + crf, + preset, + } = state + { + let mut config = VideoRecorderConfig::new(width, height, *fps); + config.crf = *crf; + config.preset = preset.clone(); + let recorder = VideoRecorder::new(&*path, config) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + *state = RecState::Active(recorder); + } + let RecState::Active(recorder) = state else { + unreachable!("state was just made Active"); + }; + recorder + .record_frame(rgba) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + })(); + if result.is_err() { + *slot = None; + } + result + }) +} + +/// Finish the recording and return the number of frames encoded. +pub(crate) fn end() -> PyResult { + RECORDER.with(|r| match r.borrow_mut().take() { + None => Err(PyRuntimeError::new_err( + "not recording; call begin_record() first", + )), + Some(RecState::Pending { .. }) => Err(PyRuntimeError::new_err( + "no frames were drawn between begin_record() and end_record()", + )), + Some(RecState::Active(recorder)) => recorder + .finish() + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))), + }) +} + +/// Closing the window mid-recording still leaves a playable file. +pub(crate) fn finish_on_exit() { + RECORDER.with(|r| { + if let Some(RecState::Active(recorder)) = r.borrow_mut().take() { + let _ = recorder.finish(); + } + }); +} diff --git a/crates/processing_pyo3/src/video.rs b/crates/processing_pyo3/src/video.rs new file mode 100644 index 00000000..c55d962d --- /dev/null +++ b/crates/processing_pyo3/src/video.rs @@ -0,0 +1,76 @@ +use bevy::prelude::Entity; +use processing_video::{ + PlaybackMode, video_create, video_destroy, video_image, video_is_loaded, video_load, + video_position, video_resolution, video_seek, video_set_mode, video_set_paused, + video_set_speed, +}; +use pyo3::{exceptions::PyRuntimeError, prelude::*}; + +#[pyclass(unsendable)] +pub struct Video { + entity: Entity, +} + +impl Video { + pub(crate) fn image_entity(&self) -> PyResult { + video_image(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +#[pymethods] +impl Video { + #[new] + pub fn new(path: &str) -> PyResult { + let handle = video_load(path).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let entity = video_create(handle).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + pub fn is_loaded(&self) -> PyResult { + video_is_loaded(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn resolution(&self) -> PyResult<(u32, u32)> { + video_resolution(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn position(&self) -> PyResult { + video_position(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn seek(&self, seconds: f64) -> PyResult<()> { + video_seek(self.entity, seconds).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn play(&self) -> PyResult<()> { + video_set_mode(self.entity, PlaybackMode::Once) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + video_set_paused(self.entity, false).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn pause(&self) -> PyResult<()> { + video_set_paused(self.entity, true).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + #[pyo3(name = "loop")] + pub fn loop_(&self) -> PyResult<()> { + video_set_mode(self.entity, PlaybackMode::Loop) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + video_set_paused(self.entity, false).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn no_loop(&self) -> PyResult<()> { + video_set_mode(self.entity, PlaybackMode::Once) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn speed(&self, rate: f32) -> PyResult<()> { + video_set_speed(self.entity, rate).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Video { + fn drop(&mut self) { + let _ = video_destroy(self.entity); + } +} diff --git a/crates/processing_pyo3/src/webcam.rs b/crates/processing_pyo3/src/webcam.rs index 3cc06ba7..f60fca1f 100644 --- a/crates/processing_pyo3/src/webcam.rs +++ b/crates/processing_pyo3/src/webcam.rs @@ -5,13 +5,17 @@ use processing_webcam::{ }; use pyo3::{exceptions::PyRuntimeError, prelude::*}; -use crate::graphics::Image; - #[pyclass(unsendable)] pub struct Webcam { entity: Entity, } +impl Webcam { + pub(crate) fn image_entity(&self) -> PyResult { + webcam_image(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + #[pymethods] impl Webcam { #[new] @@ -40,12 +44,6 @@ impl Webcam { pub fn resolution(&self) -> PyResult<(u32, u32)> { webcam_resolution(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } - - pub fn image(&self) -> PyResult { - let entity = - webcam_image(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - Ok(Image::from_entity(entity)) - } } impl Drop for Webcam { diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index 146a9bbe..07e2bf86 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -15,7 +15,7 @@ x11 = ["bevy/x11"] bevy = { workspace = true } bevy_naga_reflect = { workspace = true } # direct wgpu dep to reach `wgpu::util::TextureBlitter` (unifies to bevy's wgpu) -wgpu = "29" +wgpu = "30" naga = { workspace = true } wesl = { workspace = true } lyon = "1.0" @@ -38,6 +38,8 @@ 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"] } +objc2-metal = { version = "0.3", features = ["objc2-io-surface"] } +objc2-io-surface = "0.3" [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_LibraryLoader"] } diff --git a/crates/processing_render/shaders/processing/prims.wesl b/crates/processing_render/shaders/processing/prims.wesl new file mode 100644 index 00000000..a15d8c91 --- /dev/null +++ b/crates/processing_render/shaders/processing/prims.wesl @@ -0,0 +1,100 @@ +// processing::prims — GPU-authored dynamic topology. +// +// A kernel imports this module and adds primitives to a `p.primitives(...)` +// target; the engine binds the target's buffers under the reserved names +// below (group 0, bindings 50-52) and draws the result indirectly, so the +// sketch never touches counts, draw arguments, or index buffers. +// +// import { processing::prims }; +// +// var a = prims::vertex(pos_i); a.color = vec4(rgb_i, alpha); +// var b = prims::vertex(pos_j); b.color = vec4(rgb_j, alpha); +// prims::line(a, b); +// +// `line`/`tri`/`point` reserve vertex slots atomically, clamp against the +// target's capacity (over-capacity primitives are dropped, never partially +// written), and count every attempt so the sketch can poll +// `target.emitted()` / `target.overflowed()`. + +// Indirect draw arguments with stats in the tail. The GPU's draw_indirect +// reads only the first four words; `instance_count` stays 1 and is written +// by the engine at frame reset. +struct PrimsArgs { + vertex_count: atomic, + instance_count: u32, + first_vertex: u32, + first_instance: u32, + // Primitives kernels tried to add this frame (including dropped ones). + attempted: atomic, + // Capacity of the target, in vertices. + capacity_verts: u32, + // Vertices per primitive for the target's topology. + verts_per_prim: u32, + _pad: u32, +} + +// Reserved bindings, filled by `apply(kernel, primitives=target)`. +// Bindings 50-52 in group 0 are reserved for this module; user kernels +// should number their own bindings below 50. +@group(0) @binding(50) var prims_out_position: array; +@group(0) @binding(51) var prims_out_color: array; +@group(0) @binding(52) var prims_args: PrimsArgs; + +struct Vertex { + position: vec3, + color: vec4, +} + +// A vertex at `p` with default white color; set `.color` before committing. +fn vertex(p: vec3) -> Vertex { + return Vertex(p, vec4(1.0, 1.0, 1.0, 1.0)); +} + +const INVALID_SLOT: u32 = 0xffffffffu; + +// Reserves `n` contiguous vertex slots. Returns the base slot, or +// INVALID_SLOT when the target is full (with the reservation rolled back so +// the drawn count never exceeds what was actually written). +fn reserve(n: u32) -> u32 { + atomicAdd(&prims_args.attempted, 1u); + let base = atomicAdd(&prims_args.vertex_count, n); + if base + n > prims_args.capacity_verts { + atomicSub(&prims_args.vertex_count, n); + return INVALID_SLOT; + } + return base; +} + +fn write_vertex(slot: u32, v: Vertex) { + prims_out_position[slot * 3u + 0u] = v.position.x; + prims_out_position[slot * 3u + 1u] = v.position.y; + prims_out_position[slot * 3u + 2u] = v.position.z; + prims_out_color[slot * 4u + 0u] = v.color.x; + prims_out_color[slot * 4u + 1u] = v.color.y; + prims_out_color[slot * 4u + 2u] = v.color.z; + prims_out_color[slot * 4u + 3u] = v.color.w; +} + +// Adds one point primitive to a POINTS target. +fn point(a: Vertex) { + let base = reserve(1u); + if base == INVALID_SLOT { return; } + write_vertex(base, a); +} + +// Adds one line segment to a LINES target. +fn line(a: Vertex, b: Vertex) { + let base = reserve(2u); + if base == INVALID_SLOT { return; } + write_vertex(base, a); + write_vertex(base + 1u, b); +} + +// Adds one triangle to a TRIANGLES target. +fn tri(a: Vertex, b: Vertex, c: Vertex) { + let base = reserve(3u); + if base == INVALID_SLOT { return; } + write_vertex(base, a); + write_vertex(base + 1u, b); + write_vertex(base + 2u, c); +} diff --git a/crates/processing_render/src/animation.rs b/crates/processing_render/src/animation.rs new file mode 100644 index 00000000..d89652b1 --- /dev/null +++ b/crates/processing_render/src/animation.rs @@ -0,0 +1,163 @@ +//! Animation pose pools: K paused puppet instances of a rigged glTF scene, +//! each seeked to k/K of one animation clip, giving K static skeleton poses +//! whose joint matrices bevy uploads every frame. Instanced particles sample +//! the pool by a per-particle phase attribute (see `particles/pack.rs`), so +//! thousands of instances animate at individual phases with no per-instance +//! animation state — poses are a pure function of the phase attribute, which +//! keeps frame-clock determinism. + +use bevy::animation::{AnimationClip, AnimationPlayer, graph::AnimationGraphHandle}; +use bevy::camera::visibility::{SetViewVisibility, ViewVisibility, VisibilitySystems}; +use bevy::gltf::Gltf; +use bevy::mesh::skinning::SkinnedMesh; +use bevy::prelude::*; +use bevy::world_serialization::WorldInstanceSpawner; + +use processing_core::error::{ProcessingError, Result}; + +pub struct AnimationPoolPlugin; + +impl Plugin for AnimationPoolPlugin { + fn build(&self, app: &mut App) { + app.add_systems( + PostUpdate, + force_puppet_visibility.in_set(VisibilitySystems::CheckVisibility), + ); + } +} + +/// A pose pool: `slots[k]` is the skinned-mesh entity of the puppet posed at +/// phase k/K. The render-side pack pipeline reads each slot's live skin +/// index out of `SkinUniforms` every frame (offsets are not stable). +#[derive(Component)] +pub struct SkinPosePool { + pub slots: Vec, + instances: Vec, +} + +/// Marker on puppet skinned-mesh entities. Puppets are `Visibility::Hidden` +/// (never queued for rendering by any view), but skin extraction only +/// allocates joint matrices for view-visible entities — this marker forces +/// their `ViewVisibility` through bevy's sanctioned custom-visibility hook so +/// `SkinUniforms` stays populated. +#[derive(Component)] +pub struct SkinPoolPuppet; + +fn force_puppet_visibility(mut puppets: Query<&mut ViewVisibility, With>) { + for mut view_visibility in &mut puppets { + view_visibility.set_visible(); + } +} + +/// Spawn K paused puppets of `gltf_entity`'s scene, posed along `clip_name`. +pub fn pool_create( + In((gltf_entity, clip_name, k)): In<(Entity, String, u32)>, + world: &mut World, +) -> Result { + if k == 0 { + return Err(ProcessingError::InvalidArgument( + "animation pool size must be at least 1".to_string(), + )); + } + let handle = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)? + .gltf_handle() + .clone(); + + let (scene_handle, clip_handle) = { + let gltf_assets = world.resource::>(); + let gltf = gltf_assets + .get(&handle) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF asset not found".into()))?; + let scene = gltf + .default_scene + .clone() + .or_else(|| gltf.scenes.first().cloned()) + .ok_or_else(|| ProcessingError::GltfLoadError("GLTF has no scenes".into()))?; + let clip = gltf.named_animations.get(clip_name.as_str()).cloned().ok_or_else(|| { + ProcessingError::GltfLoadError(format!( + "Animation '{clip_name}' not found in GLTF (available: {:?})", + gltf.named_animations.keys().collect::>() + )) + })?; + (scene, clip) + }; + + let duration = world + .resource::>() + .get(&clip_handle) + .ok_or_else(|| ProcessingError::GltfLoadError("Animation clip asset not found".into()))? + .duration(); + + let (graph, clip_node) = bevy::animation::graph::AnimationGraph::from_clip(clip_handle); + let graph_handle = world.resource_mut::>().add(graph); + + let mut slots = Vec::with_capacity(k as usize); + let mut instances = Vec::with_capacity(k as usize); + for pose in 0..k { + let seek_time = duration * (pose as f32 / k as f32); + + let instance_id = world.resource_scope(|world, mut spawner: Mut| { + spawner + .spawn_sync(world, &scene_handle) + .map_err(|e| ProcessingError::GltfLoadError(format!("Puppet spawn failed: {e}"))) + })?; + instances.push(instance_id); + let entities: Vec = { + let spawner = world.resource::(); + spawner.iter_instance_entities(instance_id).collect() + }; + + let mut slot_entity = None; + for entity in entities { + // Same hygiene as gltf::load: scene cameras must not fight the + // sketch's camera. + if world.get::(entity).is_some() { + world.entity_mut(entity).remove::(); + } + // Hidden everywhere: puppets exist only to pose joint matrices. + if let Some(mut visibility) = world.get_mut::(entity) { + *visibility = Visibility::Hidden; + } + if world.get::(entity).is_some() { + world.entity_mut(entity).insert(SkinPoolPuppet); + if slot_entity.is_none() { + slot_entity = Some(entity); + } + } + if world.get::(entity).is_some() { + world + .entity_mut(entity) + .insert(AnimationGraphHandle(graph_handle.clone())); + let mut player = world.get_mut::(entity).unwrap(); + player.play(clip_node).seek_to(seek_time).pause(); + } + } + + slots.push(slot_entity.ok_or_else(|| { + ProcessingError::GltfLoadError( + "GLTF scene has no skinned mesh; an animation pool needs a rigged mesh" + .to_string(), + ) + })?); + } + + Ok(world.spawn(SkinPosePool { slots, instances }).id()) +} + +/// Destroy a pose pool and all K puppet scene instances it spawned. +pub fn pool_destroy(In(pool_entity): In, world: &mut World) -> Result<()> { + let instances = world + .get::(pool_entity) + .ok_or(ProcessingError::InvalidEntity)? + .instances + .clone(); + world.resource_scope(|world, mut spawner: Mut| { + for instance_id in &instances { + spawner.despawn_instance_sync(world, instance_id); + } + }); + world.despawn(pool_entity); + Ok(()) +} diff --git a/crates/processing_render/src/capture_macos.rs b/crates/processing_render/src/capture_macos.rs new file mode 100644 index 00000000..71cabb97 --- /dev/null +++ b/crates/processing_render/src/capture_macos.rs @@ -0,0 +1,197 @@ +//! GPU-resident frame capture into IOSurfaces (macOS only). +//! +//! The wgpu side of the hardware recording path: an IOSurface (backing a +//! CVPixelBuffer owned by the AVFoundation recorder in `processing_video`) is +//! wrapped as a Metal texture, that texture is wrapped as a wgpu texture via +//! wgpu-hal, and the canvas is blitted into it on the GPU. A completion +//! callback registered on the queue reports when the surface actually holds +//! the frame. No pixel ever crosses to the CPU. +//! +//! This module knows nothing about CoreVideo or AVFoundation — it sees only a +//! raw `IOSurfaceRef` pointer and its stable id. Everything is behind +//! `cfg(target_os = "macos")` at the module declaration. + +use std::collections::HashMap; + +use bevy::prelude::*; +use bevy::render::render_resource::Texture; +use bevy::render::renderer::{RenderDevice, RenderQueue}; +use objc2::runtime::ProtocolObject; +use objc2_io_surface::IOSurfaceRef; +use objc2_metal::{ + MTLDevice, MTLPixelFormat, MTLStorageMode, MTLTextureDescriptor, MTLTextureType, + MTLTextureUsage, +}; +use processing_core::error::{ProcessingError, Result}; + +use crate::graphics::Graphics; + +/// The pixel layout of every capture surface: BGRA8, with sRGB encode done by +/// the render hardware on write. Matches the recorder's +/// `kCVPixelFormatType_32BGRA` pool. +const CAPTURE_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Bgra8UnormSrgb; + +/// GPU work the render side hands back once the blit into the surface has +/// completed on the queue. +pub type CaptureDone = Box; + +/// Per-graphics cache of IOSurface texture wraps. The recorder's pool +/// recycles a handful of surfaces for the whole recording, so each one is +/// wrapped exactly once; the map is keyed by the surface's stable id. +#[derive(Component, Default)] +pub struct CaptureCache { + textures: HashMap, + blitter: Option, + width: u32, + height: u32, +} + +/// Wrap an IOSurface as a renderable wgpu texture on bevy's device. +/// +/// SAFETY contract with the caller: `surface_ptr` is a live `IOSurfaceRef` +/// whose dimensions are exactly `width`×`height` and whose pixel format is +/// BGRA8. The Metal texture retains the surface, so the wrap stays valid in +/// the cache even across the caller releasing its own reference. +fn wrap_iosurface( + device: &wgpu::Device, + surface_ptr: usize, + width: u32, + height: u32, +) -> Result { + let surface: &IOSurfaceRef = unsafe { &*(surface_ptr as *const IOSurfaceRef) }; + + let hal_device = unsafe { device.as_hal::() }.ok_or_else(|| { + ProcessingError::InvalidArgument( + "GPU capture requires the Metal backend".to_string(), + ) + })?; + + let desc = unsafe { + MTLTextureDescriptor::texture2DDescriptorWithPixelFormat_width_height_mipmapped( + MTLPixelFormat::BGRA8Unorm_sRGB, + width as usize, + height as usize, + false, + ) + }; + desc.setUsage(MTLTextureUsage::RenderTarget); + desc.setStorageMode(MTLStorageMode::Shared); + + let raw_device: &ProtocolObject = hal_device.raw_device(); + let mtl_texture = raw_device + .newTextureWithDescriptor_iosurface_plane(&desc, surface, 0) + .ok_or_else(|| { + ProcessingError::InvalidArgument("Metal refused to wrap the IOSurface".to_string()) + })?; + + let hal_texture = unsafe { + wgpu::hal::metal::Device::texture_from_raw( + mtl_texture, + CAPTURE_FORMAT, + MTLTextureType::Type2D, + 1, + 1, + wgpu::hal::CopyExtent { + width, + height, + depth: 1, + }, + None, + ) + }; + let texture = unsafe { + device.create_texture_from_hal::( + hal_texture, + &wgpu::TextureDescriptor { + label: Some("gpu capture iosurface"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: CAPTURE_FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }, + wgpu::wgt::TextureUses::UNINITIALIZED, + ) + }; + Ok(texture) +} + +/// Blit the canvas into the given IOSurface and register `on_done` to fire +/// once the copy has completed on the GPU. The blit converts whatever the +/// canvas format is (HDR included) into sRGB-encoded BGRA8 — the same plain +/// blit policy as the readback ring, so captures match the screen. +pub fn capture_into_surface( + In((entity, texture, surface_ptr, surface_id, on_done)): In<( + Entity, + Texture, + usize, + u32, + CaptureDone, + )>, + world: &mut World, +) -> Result<()> { + let render_device = world.resource::().clone(); + let render_queue = world.resource::().clone(); + let size = world + .get::(entity) + .ok_or(ProcessingError::GraphicsNotFound)? + .size; + + if world.get::(entity).is_none() { + world.entity_mut(entity).insert(CaptureCache::default()); + } + { + let mut cache = world.get_mut::(entity).unwrap(); + if cache.width != size.width || cache.height != size.height { + cache.textures.clear(); + cache.width = size.width; + cache.height = size.height; + } + if !cache.textures.contains_key(&surface_id) { + let wrapped = + wrap_iosurface(render_device.wgpu_device(), surface_ptr, size.width, size.height)?; + cache.textures.insert(surface_id, wrapped); + } + if cache.blitter.is_none() { + cache.blitter = Some(wgpu::util::TextureBlitter::new( + render_device.wgpu_device(), + CAPTURE_FORMAT, + )); + } + } + + let cache = world.get::(entity).unwrap(); + let dst = cache.textures.get(&surface_id).unwrap(); + let blitter = cache.blitter.as_ref().unwrap(); + + let src_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let dst_view = dst.create_view(&wgpu::TextureViewDescriptor::default()); + let mut encoder = render_device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpu capture blit"), + }); + blitter.copy( + render_device.wgpu_device(), + &mut encoder, + &src_view, + &dst_view, + ); + render_queue.submit(std::iter::once(encoder.finish())); + render_queue.on_submitted_work_done(on_done); + Ok(()) +} + +/// Drop the capture cache (wrapped textures and blitter) for a graphics. +/// Called when a recording ends; the pool the surfaces came from is gone. +pub fn capture_reset(In(entity): In, world: &mut World) -> Result<()> { + if let Ok(mut e) = world.get_entity_mut(entity) { + e.remove::(); + } + Ok(()) +} diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs index 4b56fe6a..1f8b7d94 100644 --- a/crates/processing_render/src/compute.rs +++ b/crates/processing_render/src/compute.rs @@ -155,7 +155,10 @@ pub fn read_buffer_gpu( .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(); + let bytes = buffer_slice + .get_mapped_range() + .map_err(|e| ProcessingError::BufferMapError(format!("get_mapped_range failed: {e}")))? + .to_vec(); readback_buffer.unmap(); Ok(bytes) } @@ -280,6 +283,7 @@ pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { shader_defs: Vec::new(), entry_point: Some(entry_point.clone().into()), zero_initialize_workgroup_memory: true, + constants: default(), }; let pipeline_id = app @@ -359,6 +363,24 @@ pub fn dispatch( } } + // A short bind group is a FATAL wgpu validation error, not a warning. + // `create_bindings` skips any parameter it can't resolve yet (an + // unset buffer/texture, or a GpuImage/GpuShaderBuffer asset not + // prepared in this frame) — fail with the parameter names instead. + if bindings.len() != desc.entries.len() { + let bound: Vec = bindings.iter().map(|(b, _)| *b).collect(); + let missing: Vec = reflection + .parameters() + .filter(|p| p.group() == *group && !bound.contains(&p.binding())) + .filter_map(|p| p.name().map(|n| n.to_string())) + .collect(); + return Err(ProcessingError::InvalidArgument(format!( + "compute dispatch: unresolved @group({group}) bindings {missing:?} — \ + set them with set-uniforms! (a texture created this frame needs one \ + engine update before it can bind; draw a frame first)" + ))); + } + let bind_group_entries: Vec<_> = bindings .iter() .map( diff --git a/crates/processing_render/src/gltf.rs b/crates/processing_render/src/gltf.rs index 7b9397be..147dea9b 100644 --- a/crates/processing_render/src/gltf.rs +++ b/crates/processing_render/src/gltf.rs @@ -9,6 +9,7 @@ use bevy::{ camera::visibility::RenderLayers, ecs::system::RunSystemOnce, gltf::{Gltf, GltfMaterial, GltfMeshName}, + mesh::Indices, pbr::ExtendedMaterial, prelude::*, world_serialization::WorldInstanceSpawner, @@ -18,6 +19,7 @@ use crate::geometry::{BuiltinAttributes, Geometry, layout::VertexLayout}; use crate::graphics; use crate::material::ProcessingMaterial; use crate::render::material::{ProcessingExtendedMaterial, UntypedMaterial}; +use crate::render::primitive::ensure_vertex_colors; use processing_core::config::{Config, ConfigKey}; use processing_core::error::{ProcessingError, Result}; @@ -71,6 +73,12 @@ pub struct GltfHandle { graphics_entity: Entity, } +impl GltfHandle { + pub(crate) fn gltf_handle(&self) -> &Handle { + &self.handle + } +} + pub fn load( In((graphics_entity, path)): In<(Entity, String)>, world: &mut World, @@ -128,6 +136,22 @@ pub fn load( Ok(entity) } +/// Destroy a loaded glTF handle and the scene instance spawned for queries. +/// Shapes/materials explicitly extracted from it are independent Processing +/// resources and retain their own handles until their respective destroy API +/// is called. +pub fn destroy(In(gltf_entity): In, world: &mut World) -> Result<()> { + let instance_id = world + .get::(gltf_entity) + .ok_or(ProcessingError::InvalidEntity)? + .instance_id; + world.resource_scope(|world, mut spawner: Mut| { + spawner.despawn_instance_sync(world, &instance_id); + }); + world.despawn(gltf_entity); + Ok(()) +} + pub fn geometry( In((gltf_entity, name)): In<(Entity, String)>, world: &mut World, @@ -165,6 +189,8 @@ pub fn geometry( (handle, transform) }; + prepare_for_draw(world, &mesh_handle); + let builtins = world.resource::(); let attrs = vec![ builtins.position, @@ -182,6 +208,39 @@ pub fn geometry( Ok(entity) } +/// Bring a gltf mesh up to the shape the immediate-mode draw paths expect. +/// +/// Meshes the engine builds itself all carry an index buffer and a vertex +/// color attribute, and the `VertexLayout` this module declares for a gltf +/// geometry names `color` too. A gltf primitive is whatever the file and the +/// loader between them produced, so both have to be guaranteed here instead of +/// assumed. Neither changes how the mesh looks. +fn prepare_for_draw(world: &mut World, handle: &Handle) { + let mut meshes = world.resource_mut::>(); + let Some(mut mesh) = meshes.get_mut(handle) else { + return; + }; + ensure_indexed(&mut mesh); + ensure_vertex_colors(&mut mesh); +} + +/// Give a mesh the trivial index buffer if it arrived without one, so that +/// `stroke()` can draw it. +/// +/// The wireframe pass pulls its vertices through the index buffer and quietly +/// skips any mesh that has none, so a non-indexed mesh cannot be outlined -- +/// and gltf primitives commonly arrive without indices. `0..n` names +/// exactly the vertices the draw already walks in exactly the order it walks +/// them, so this changes nothing about how the mesh renders; it only gives +/// the wireframe pass the handle it needs. +fn ensure_indexed(mesh: &mut Mesh) { + if mesh.indices().is_some() { + return; + } + let count = mesh.count_vertices() as u32; + mesh.insert_indices(Indices::U32((0..count).collect())); +} + /// 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 diff --git a/crates/processing_render/src/graphics.rs b/crates/processing_render/src/graphics.rs index 4fc9071b..83b7de6c 100644 --- a/crates/processing_render/src/graphics.rs +++ b/crates/processing_render/src/graphics.rs @@ -4,14 +4,16 @@ //! In Bevy terms, a graphics object is represented as an entity with a camera component //! configured to render to a specific surface (either a window or an offscreen image). use bevy::{ + anti_alias::taa::TemporalAntiAliasing, camera::{ CameraMainTextureUsages, CameraOutputMode, CameraProjection, ClearColorConfig, Hdr, ImageRenderTarget, MsaaWriteback, Projection, RenderTarget, visibility::RenderLayers, }, + core_pipeline::prepass::{DepthPrepass, MotionVectorPrepass}, core_pipeline::tonemapping::Tonemapping, ecs::query::QueryEntityError, + pbr::contact_shadows::ContactShadows, math::{Mat4, Vec3A}, - post_process::bloom::Bloom, prelude::*, render::{ RenderApp, @@ -30,7 +32,7 @@ use crate::{ Flush, image::{Image, create_readback_buffer, pixel_size, pixels_to_bytes}, render::{ - BATCH_INDEX_STEP, RenderState, + BATCH_INDEX_STEP, NEAR_HEADROOM, RenderState, command::{CommandBuffer, DrawCommand}, filter, }, @@ -49,6 +51,16 @@ impl Plugin for GraphicsPlugin { } } +/// Marks a canvas that holds SIMULATION STATE rather than a picture: +/// feedback patches, accumulators, reaction-diffusion fields. Such a canvas +/// is guaranteed post-process-free — bloom and tonemapping are never applied, +/// because both write back THROUGH the view target and would re-transform the +/// stored state every frame (tonemapping converges it to the tonemapper's +/// fixed point, which looks like a mysterious blur/wash bug). Draws into it +/// also write LINEAR color, so values round-trip exactly. +#[derive(Component)] +pub struct StateCanvas; + #[derive(Component)] pub struct Graphics { readback_buffer: bevy::render::render_resource::Buffer, @@ -101,13 +113,29 @@ impl ProcessingProjection { impl CameraProjection for ProcessingProjection { fn get_clip_from_view(&self) -> Mat4 { + // Depth is REVERSED (near -> 1, far -> 0), which an ortho expresses by + // swapping near and far in the standard formula. Everything else in + // the pipeline assumes it: the depth test is GreaterEqual, the depth + // buffer clears to 0 = far, bevy's own OrthographicProjection swaps + // for this reason, FrustumProjection below derives the same convention + // by hand, and world_from_screen unprojects with `1.0 - depth`. + // + // Unswapped, this projection alone ran forward-z, and every depth + // comparison in 2d came out backwards: a later draw LOST to an earlier + // one, and the background/clear quad -- built at ndc_z ~= 0, the far + // plane under reverse-z (create_ndc_background_quad) -- landed on the + // NEAR plane, where it sorted last in Transparent3d and repainted over + // every blended draw in the frame. That is what made blended draws + // vanish inside offscreen canvases. + // + // NEAR_HEADROOM opens the near side for the per-batch z staircase. Mat4::orthographic_rh( 0.0, self.width, self.height, // bottom = height 0.0, // top = 0 - self.near, self.far, + self.near - NEAR_HEADROOM, ) } @@ -150,8 +178,82 @@ impl CameraProjection for ProcessingProjection { } } +/// Off-axis (asymmetric) perspective projection — classic OpenGL/Processing +/// `frustum()`. `left`/`right`/`bottom`/`top` bound the view volume on the +/// near plane, in view-space units. +#[derive(Debug, Clone, Reflect)] +pub struct FrustumProjection { + pub left: f32, + pub right: f32, + pub bottom: f32, + pub top: f32, + pub near: f32, + pub far: f32, +} + +impl CameraProjection for FrustumProjection { + fn get_clip_from_view(&self) -> Mat4 { + // The standard glFrustum matrix, except depth is REVERSED (near → 1, + // far → 0) to match the rest of the pipeline — bevy's perspective is + // infinite-reverse and its ortho swaps near/far for the same reason. + // An ortho can reverse by swapping near/far in the standard formula; + // a frustum cannot (near also sets the x/y scale), so the z column is + // derived directly: it hits 1 at z_view = −near and 0 at z_view = −far. + let (l, r, b, t, n, f) = ( + self.left, + self.right, + self.bottom, + self.top, + self.near, + self.far, + ); + Mat4::from_cols( + Vec4::new(2.0 * n / (r - l), 0.0, 0.0, 0.0), + Vec4::new(0.0, 2.0 * n / (t - b), 0.0, 0.0), + Vec4::new((r + l) / (r - l), (t + b) / (t - b), n / (f - n), -1.0), + Vec4::new(0.0, 0.0, n * f / (f - n), 0.0), + ) + } + + fn get_clip_from_view_for_sub(&self, _sub_view: &bevy::camera::SubCameraView) -> Mat4 { + self.get_clip_from_view() + } + + fn update(&mut self, _width: f32, _height: f32) { + // Explicit view-volume bounds don't track the window size. + } + + fn far(&self) -> f32 { + self.far + } + + fn get_frustum_corners(&self, z_near: f32, z_far: f32) -> [Vec3A; 8] { + // The near-plane bounds scale linearly with distance along the rays. + let sn = z_near.abs() / self.near; + let sf = z_far.abs() / self.near; + [ + // near plane + Vec3A::new(self.right * sn, self.bottom * sn, z_near), // bottom-right + Vec3A::new(self.right * sn, self.top * sn, z_near), // top-right + Vec3A::new(self.left * sn, self.top * sn, z_near), // top-left + Vec3A::new(self.left * sn, self.bottom * sn, z_near), // bottom-left + // far plane + Vec3A::new(self.right * sf, self.bottom * sf, z_far), // bottom-right + Vec3A::new(self.right * sf, self.top * sf, z_far), // top-right + Vec3A::new(self.left * sf, self.top * sf, z_far), // top-left + Vec3A::new(self.left * sf, self.bottom * sf, z_far), // bottom-left + ] + } +} + pub fn create( - In((width, height, surface_entity, texture_format)): In<(u32, u32, Entity, TextureFormat)>, + In((width, height, surface_entity, texture_format, state)): In<( + u32, + u32, + Entity, + TextureFormat, + bool, + )>, mut commands: Commands, mut layer_manager: ResMut, p_images: Query<&Image, With>, @@ -219,7 +321,18 @@ pub fn create( render_layer, CommandBuffer::new(), RenderState::default(), - crate::color::ColorMode::default(), + // A state canvas takes LINEAR color: the host normalizes fill/stroke + // to 0..1 and the engine would otherwise read those as sRGB, so + // fill(0.5) would land at linear 0.21 — fatal when the "color" is a + // reagent concentration rather than a picture. + if state { + crate::color::ColorMode { + space: crate::color::ColorSpace::Linear, + max: [1.0; 4], + } + } else { + crate::color::ColorMode::default() + }, SurfaceSize(width, height), Graphics { readback_buffer, @@ -228,8 +341,34 @@ pub fn create( }, )); - if is_hdr { - entity_commands.insert((Hdr, Bloom::NATURAL, Tonemapping::TonyMcMapface)); + // Physical-lux contract: EV100 9.7 (Blender's default, and bevy's own + // implicit default) unless overridden for calibration probes. + entity_commands.insert(bevy::camera::Exposure { + ev100: std::env::var("PROCESSING_EV100") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(9.7), + }); + + if state { + // Float precision, zero post-processing: the canvas IS the data. + entity_commands.insert((StateCanvas, Hdr)); + } else if is_hdr { + // Bloom is OPT-IN: a canvas gets HDR + the tonemapper, but no bloom + // until `bloom()` asks for it. Shipping `Bloom::NATURAL` here meant + // every canvas had UN-THRESHOLDED bloom (intensity 0.15, prefilter + // threshold 0.0) without asking — which contradicts the documented + // API (`no_bloom` "disables bloom previously enabled with bloom") + // and, in a dense scene, hazes the whole frame including areas with + // nothing drawn in them. It also skips the bloom node entirely when + // unused, which is free performance at show resolutions. + entity_commands.insert((Hdr, Tonemapping::TonyMcMapface)); + } + + // TEMP(bevy-020 debug): force direct drawing to bisect the indirect-args + // build for sorted instance batches. + if std::env::var("PROCESSING_NO_INDIRECT").is_ok() { + entity_commands.insert(bevy::render::view::NoIndirectDrawing); } let entity = entity_commands.id(); @@ -237,6 +376,85 @@ pub fn create( Ok(entity) } +/// Turn screen-space contact shadows on or off for this canvas' camera. +/// +/// Contact shadows are the short dark line where two surfaces meet -- box on +/// box, box on ground. Shadow maps do the big forms; at cascade resolution +/// they cannot resolve a contact, which is what makes a dense pile of objects +/// read as one mass instead of many things. +/// +/// `length` is in WORLD units, not pixels: it is how far the ray marches from +/// the contact, so it scales with the scene, not with the window. `thickness` +/// is likewise world units -- the depth buffer only carries 2.5D information, +/// so the effect has to assume some thickness for what it finds there. +/// `linear_steps` is the per-pixel raymarch step count, and the one to spend +/// down if the effect costs too much at delivery resolution. +/// +/// A canvas is a camera here, so this applies to whichever canvas is passed: +/// the primary, a `create_offscreen` buffer, or a `create_window` canvas. +/// +/// `ContactShadows` requires a depth prepass, which bevy attaches for us. We +/// take it away again on disable -- nothing else in the engine asks for a +/// prepass, so leaving one running would be paying for a full depth pass per +/// frame to feed an effect that is off. +pub fn set_contact_shadows( + In((entity, enabled, linear_steps, thickness, length)): In<(Entity, bool, u32, f32, f32)>, + mut commands: Commands, + graphics: Query<(), With>, +) -> Result<()> { + graphics + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + + if enabled { + commands.entity(entity).insert(ContactShadows { + linear_steps, + thickness, + length, + }); + } else { + commands + .entity(entity) + .remove::() + .remove::(); + } + Ok(()) +} + +/// Turn temporal antialiasing on or off for this canvas' camera. +/// +/// Kept separate from `set_contact_shadows` deliberately, even though bevy's +/// own contact-shadows example pairs them: TAA rewrites how the WHOLE canvas +/// is antialiased, adds a motion-vector prepass, and jitters the projection +/// every frame. Turning that on as a side effect of asking for a lighting +/// detail would be a large, invisible change to a sketch's whole image. +/// +/// TAA requires MSAA off -- the two are different answers to the same +/// question and bevy cannot run both -- so this switches MSAA off with it and +/// restores the default sample count on the way out. +pub fn set_temporal_aa( + In((entity, enabled)): In<(Entity, bool)>, + mut commands: Commands, + graphics: Query<(), With>, +) -> Result<()> { + graphics + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + + if enabled { + commands + .entity(entity) + .insert((TemporalAntiAliasing::default(), Msaa::Off)); + } else { + commands + .entity(entity) + .remove::() + .remove::() + .insert(Msaa::default()); + } + Ok(()) +} + #[allow(dead_code)] pub fn resize( In((entity, width, height)): In<(Entity, u32, u32)>, @@ -367,7 +585,7 @@ pub fn perspective( aspect_ratio, near, far, - near_clip_plane, + near_clip_plane: _, }, )): In<(Entity, PerspectiveProjection)>, mut projections: Query<&mut Projection>, @@ -376,12 +594,22 @@ pub fn perspective( .get_mut(entity) .map_err(|_| ProcessingError::GraphicsNotFound)?; - *projection = Projection::Perspective(PerspectiveProjection { - fov, - aspect_ratio, + // An explicit perspective() must honor ALL its parameters — including a + // deliberately anamorphic aspect ratio. Bevy's PerspectiveProjection + // cannot: its update() overwrites aspect_ratio with the viewport aspect + // on every render-area pass, silently discarding the caller's value. So + // express the same view volume as the equivalent symmetric frustum, + // whose update() is inert. The window-tracking default stays with + // mode_3d's Projection::Perspective. + let top = near * (fov / 2.0).tan(); + let right = top * aspect_ratio; + *projection = Projection::custom(FrustumProjection { + left: -right, + right, + bottom: -top, + top, near, far, - near_clip_plane, }); Ok(()) @@ -426,6 +654,53 @@ pub fn ortho( Ok(()) } +pub struct FrustumArgs { + pub left: f32, + pub right: f32, + pub bottom: f32, + pub top: f32, + pub near: f32, + pub far: f32, +} + +/// glFrustum-style asymmetric perspective: the bounds are on the NEAR plane. +pub fn frustum( + In(( + entity, + FrustumArgs { + left, + right, + bottom, + top, + near, + far, + }, + )): In<(Entity, FrustumArgs)>, + mut projections: Query<&mut Projection>, +) -> Result<()> { + let mut projection = projections + .get_mut(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)?; + + if !(near > 0.0 && far > near && right != left && top != bottom) { + return Err(ProcessingError::InvalidArgument(format!( + "frustum requires 0 < near < far and non-empty bounds, \ + got left={left} right={right} bottom={bottom} top={top} near={near} far={far}" + ))); + } + + *projection = Projection::custom(FrustumProjection { + left, + right, + bottom, + top, + near, + far, + }); + + Ok(()) +} + pub fn world_from_screen( In((entity, sx, sy, depth)): In<(Entity, f32, f32, f32)>, cameras: Query<(&bevy::camera::Camera, &GlobalTransform)>, @@ -448,9 +723,17 @@ pub fn set_bloom( In((entity, intensity, threshold)): In<(Entity, f32, f32)>, mut commands: Commands, mut tonemapping_query: Query<&mut Tonemapping>, + state_canvases: Query<(), With>, ) -> Result<()> { use bevy::post_process::bloom::{Bloom, BloomCompositeMode, BloomPrefilter}; + // Post-processing on a state canvas would corrupt the stored state; the + // guarantee is the whole point of asking for one. + if state_canvases.get(entity).is_ok() { + warn!("bloom() ignored: this graphics is a state canvas (post-process-free by contract)"); + return Ok(()); + } + let mut bloom = Bloom::NATURAL; bloom.intensity = intensity; if threshold > 0.0 { @@ -504,6 +787,7 @@ pub fn destroy( } pub fn begin_draw(In(entity): In, mut state_query: Query<&mut RenderState>) -> Result<()> { + if std::env::var("PROCESSING_TRACE_VT").is_ok() { eprintln!("ENGINE begin_draw ==== FRAME ===="); } let mut state = state_query .get_mut(entity) .map_err(|_| ProcessingError::GraphicsNotFound)?; @@ -551,10 +835,80 @@ pub fn screen_point( )) } +/// A canvas that has finished a frame and is waiting for the next +/// `app.update()` to put it on screen, instead of forcing an update of its +/// own. See [`arm_present`]. +#[derive(Component)] +pub struct PendingPresent; + +/// Arm `entity` to present on the next update rather than presenting now. +/// +/// `present` costs a whole `app.update()` — around 3ms of fixed CPU work +/// regardless of what was drawn — so a sketch driving N windows through the +/// usual `endDraw`-per-canvas path pays N of them per frame and falls off a +/// cliff by a couple of dozen windows. The renderer never needed that: a +/// camera is active exactly when its graphics carries `Flush` +/// (`render::activate_cameras`), so any number of canvases can render and +/// present within a SINGLE update. Arming marks the canvas ready and leaves +/// the update to whoever runs one next — in practice the primary window's +/// `endDraw`, which Processing always calls at the end of every frame. +/// +/// Only safe for canvases nothing samples back: a window surface is never an +/// image source, so no draw call can observe the delay. Offscreen buffers +/// keep presenting synchronously. +pub fn arm_present(app: &mut App, entity: Entity) -> Result<()> { + let mut graphics = graphics_mut!(app, entity); + graphics + .get_mut::() + .ok_or(ProcessingError::GraphicsNotFound)? + .output_mode = CameraOutputMode::Write { + blend_state: None, + clear_color: ClearColorConfig::None, + }; + graphics.insert((Flush, PendingPresent)); + Ok(()) +} + +/// Stand every armed canvas back down after an update presented it. +fn disarm_pending(app: &mut App) { + let world = app.world_mut(); + let armed: Vec = world + .query_filtered::>() + .iter(world) + .collect(); + for entity in armed { + let Ok(mut graphics) = world.get_entity_mut(entity) else { + continue; + }; + if let Some(mut camera) = graphics.get_mut::() { + camera.output_mode = CameraOutputMode::Skip; + } + graphics.remove::<(Flush, PendingPresent)>(); + } +} + +/// Whether `entity` has draw commands recorded since its last flush. +/// +/// A canvas with an empty command buffer has already rendered everything it +/// was asked to, so flushing it again is a whole wasted `app.update()`. The +/// blit path (`image(pg, ...)`) flushes its source defensively, which at a +/// hundred-odd offscreen canvases per frame is the single largest cost in the +/// frame; this lets it skip the ones that are already clean. +pub fn has_pending(In(entity): In, buffers: Query<&CommandBuffer>) -> Result { + Ok(!buffers + .get(entity) + .map_err(|_| ProcessingError::GraphicsNotFound)? + .commands + .is_empty()) +} + pub fn flush(app: &mut App, entity: Entity) -> Result<()> { + if std::env::var("PROCESSING_TRACE_VT").is_ok() { eprintln!("ENGINE flush+update"); } graphics_mut!(app, entity).insert(Flush); app.update(); graphics_mut!(app, entity).remove::(); + // Whatever was armed for presentation rode along on that update. + disarm_pending(app); Ok(()) } @@ -624,25 +978,66 @@ pub struct ReadbackData { } pub fn readback_raw( - In((entity, texture)): In<(Entity, Texture)>, - graphics_query: Query<&Graphics>, - render_device: Res, - render_queue: Res, + In((entity, texture, lut_view)): In<(Entity, Texture, Option)>, + world: &mut World, ) -> Result { - let graphics = graphics_query - .get(entity) - .map_err(|_| ProcessingError::GraphicsNotFound)?; + let render_device = world.resource::().clone(); + let render_queue = world.resource::().clone(); + let (source_format, size, readback_buffer) = { + let graphics = world + .get::(entity) + .ok_or(ProcessingError::GraphicsNotFound)?; + ( + graphics.texture_format, + graphics.size, + graphics.readback_buffer.clone(), + ) + }; - let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + // Same conversion policy as the async ring: 8-bit surfaces read back + // as-is; HDR surfaces convert on the GPU to 8-bit sRGB, honoring the + // camera's tonemapping — so q/save/loadPixels see what the screen shows. + let needs_convert = !matches!( + source_format, + TextureFormat::Rgba8UnormSrgb + | TextureFormat::Rgba8Unorm + | TextureFormat::Bgra8UnormSrgb + | TextureFormat::Bgra8Unorm + ); + let format = if needs_convert { + TextureFormat::Rgba8UnormSrgb + } else { + source_format + }; + let px_size = pixel_size(format)?; + let bytes_per_row = size.width as usize * px_size; + let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(bytes_per_row); - let px_size = pixel_size(graphics.texture_format)?; - let padded_bytes_per_row = - RenderDevice::align_copy_bytes_per_row(graphics.size.width as usize * px_size); + if world.get::(entity).is_none() { + world.entity_mut(entity).insert(ReadbackRing::default()); + } + let copy_src = prepare_convert_source( + world, + entity, + &render_device, + &texture, + needs_convert, + size, + &lut_view, + ); + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + if needs_convert { + let src_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let ring = world.get::(entity).unwrap(); + let convert = ring.convert.as_ref().unwrap(); + record_convert(&render_device, &mut encoder, convert, &src_view); + } encoder.copy_texture_to_buffer( - texture.as_image_copy(), + copy_src.as_image_copy(), TexelCopyBufferInfo { - buffer: &graphics.readback_buffer, + buffer: &readback_buffer, layout: TexelCopyBufferLayout { offset: 0, bytes_per_row: Some( @@ -653,12 +1048,15 @@ pub fn readback_raw( rows_per_image: None, }, }, - graphics.size, + size, ); render_queue.submit(std::iter::once(encoder.finish())); - let buffer_slice = graphics.readback_buffer.slice(..); + // The readback buffer is sized for the (larger or equal) source format; + // map only the region this copy wrote. + let mapped_len = padded_bytes_per_row as u64 * size.height as u64; + let buffer_slice = readback_buffer.slice(..mapped_len); let (s, r) = crossbeam_channel::bounded(1); @@ -673,15 +1071,17 @@ pub fn readback_raw( r.recv().expect("Failed to receive the map_async message"); - let data = buffer_slice.get_mapped_range().to_vec(); + let data = buffer_slice + .get_mapped_range() + .expect("Failed to get mapped range for readback") + .to_vec(); - graphics.readback_buffer.unmap(); + readback_buffer.unmap(); // strip row padding - let bytes_per_row = graphics.size.width as usize * px_size; let unpadded = if padded_bytes_per_row != bytes_per_row { data.chunks_exact(padded_bytes_per_row) - .take(graphics.size.height as usize) + .take(size.height as usize) .flat_map(|row| &row[..bytes_per_row]) .copied() .collect() @@ -691,12 +1091,622 @@ pub fn readback_raw( Ok(ReadbackData { bytes: unpadded, - format: graphics.texture_format, - width: graphics.size.width, - height: graphics.size.height, + format, + width: size.width, + height: size.height, }) } +// ── Asynchronous readback ring ────────────────────────────────────────────── +// The synchronous readback above stalls the caller for a full pipeline flush +// plus the GPU→CPU copy every call — at 3840×4320 that is most of a frame's +// wall time and dominates recording. The ring splits the operation: `enqueue` +// submits the copy and returns immediately; `fetch` hands back frames whose +// map has completed, in submission order. With a few frames in flight the +// copy of frame N overlaps the render of frame N+1 and readback mostly +// leaves the critical path. Consumers that tolerate a frame or two of +// latency (the video recorder) use this; point queries keep the sync path. + +/// Frames allowed in flight before `readback_ring_enqueue` refuses. Bounds +/// memory (one full frame each — ~66MB at 3840×4320) and bounds how far the +/// producer can run ahead. +pub const READBACK_RING_DEPTH: usize = 3; + +/// A queued readback's mapping is still in flight. +const READBACK_PENDING: u8 = 0; +/// It mapped, and the bytes can be read. +const READBACK_READY: u8 = 1; +/// It will never map. wgpu calls the `map_async` callback with an error when +/// the device goes away underneath a copy that is still in flight, which is +/// the ordinary way a recording sketch exits: the window closes, the device is +/// torn down, and whatever was queued that frame aborts. +const READBACK_FAILED: u8 = 2; + +struct PendingReadback { + buffer: bevy::render::render_resource::Buffer, + state: std::sync::Arc, + /// The copy's own submission, so a blocking fetch waits for exactly this + /// copy — waiting for "the most recent submission" would drain the whole + /// queue (including newer copies and the in-flight render) and serialize + /// the pipeline into burst-stall cycles. + submission: wgpu::SubmissionIndex, + padded_bytes_per_row: usize, + bytes_per_row: usize, + format: TextureFormat, + width: u32, + height: u32, +} + +/// GPU-side format conversion in front of the readback: HDR (or any +/// non-8-bit) surfaces are converted into an 8-bit sRGB texture BEFORE the +/// copy, so the readback moves half the bytes and the CPU never touches a +/// float pixel. (A 3840×4320 Rgba16Float frame is 132MB and a per-pixel CPU +/// conversion of it costs ~350ms — it, not the copy, was the recording +/// bottleneck.) +/// +/// The conversion honors the camera's tonemapping so captures match the +/// screen: `Tonemapping::None` (the show's calibrated mode) is a plain blit +/// — byte-identical to the previous behavior — while `TonyMcMapface` runs +/// bevy's own LUT in a tiny fullscreen pass. Without this, captures of lit +/// scenes read the raw HDR canvas and clamp everything bright to white. +enum ConvertMode { + Blit(wgpu::util::TextureBlitter), + Tonemap { + pipeline: wgpu::RenderPipeline, + bind_group_layout: wgpu::BindGroupLayout, + lut_view: wgpu::TextureView, + lut_sampler: wgpu::Sampler, + }, +} + +struct ConvertPass { + texture: wgpu::Texture, + view: wgpu::TextureView, + mode: ConvertMode, + width: u32, + height: u32, +} + +/// Matches bevy's `sample_tony_mc_mapface_lut` (tonemapping_shared.wgsl); +/// exposure is already pre-applied during shading, so tonemapping is the +/// only remaining display transform. Output target is Rgba8UnormSrgb, so +/// the hardware performs the sRGB encode. +const TONEMAP_CONVERT_SHADER: &str = r#" +@group(0) @binding(0) var hdr_tex: texture_2d; +@group(0) @binding(1) var lut_tex: texture_3d; +@group(0) @binding(2) var lut_smp: sampler; + +@vertex +fn vs(@builtin(vertex_index) i: u32) -> @builtin(position) vec4 { + let uv = vec2(f32((i << 1u) & 2u), f32(i & 2u)); + return vec4(uv * 2.0 - 1.0, 0.0, 1.0); +} + +const LUT_DIMS: f32 = 48.0; + +fn tony_mc_mapface(stimulus: vec3) -> vec3 { + let uv = saturate( + (stimulus / (stimulus + 1.0)) * ((LUT_DIMS - 1.0) / LUT_DIMS) + 0.5 / LUT_DIMS, + ); + return textureSampleLevel(lut_tex, lut_smp, uv, 0.0).rgb; +} + +@fragment +fn fs(@builtin(position) pos: vec4) -> @location(0) vec4 { + let c = textureLoad(hdr_tex, vec2(pos.xy), 0); + return vec4(tony_mc_mapface(max(c.rgb, vec3(0.0))), 1.0); +} +"#; + +fn build_tonemap_convert( + device: &wgpu::Device, + lut_view: wgpu::TextureView, +) -> ConvertMode { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("readback tonemap convert"), + source: wgpu::ShaderSource::Wgsl(TONEMAP_CONVERT_SHADER.into()), + }); + let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("readback tonemap convert"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("readback tonemap convert"), + bind_group_layouts: &[Some(&bind_group_layout)], + immediate_size: 0, + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("readback tonemap convert"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs"), + compilation_options: Default::default(), + buffers: &[], + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs"), + compilation_options: Default::default(), + targets: &[Some(wgpu::ColorTargetState { + format: wgpu::TextureFormat::Rgba8UnormSrgb, + blend: None, + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState::default(), + depth_stencil: None, + multisample: wgpu::MultisampleState::default(), + multiview_mask: None, + cache: None, + }); + let lut_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("readback tonemap lut"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Linear, + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + ..Default::default() + }); + ConvertMode::Tonemap { + pipeline, + bind_group_layout, + lut_view, + lut_sampler, + } +} + +/// Ensure the graphics' convert pass matches the current size and camera +/// tonemapping, and return the texture the readback copy should read from. +/// The capture must show what the camera setting shows: TonyMcMapface runs +/// the LUT pass; None (and, conservatively, any variant we don't replicate) +/// is a plain blit — the raw-linear behavior every calibrated-unlit sketch +/// depends on. +fn prepare_convert_source( + world: &mut World, + entity: Entity, + render_device: &RenderDevice, + texture: &Texture, + needs_convert: bool, + size: Extent3d, + lut_view: &Option, +) -> wgpu::Texture { + if !needs_convert { + // bevy's Texture derefs to the raw wgpu texture. + return wgpu::Texture::clone(texture); + } + // The canvas texture is the camera's render TARGET: bevy's tonemapping + // node has already been applied by the time anything lands in it, for + // every Tonemapping mode. Running the LUT here again double-tonemaps — + // tony(tony(x)) plateaus at tony(1.0) ≈ sRGB 169, which masked the whole + // lit-exposure miscalibration (captures showed flat grey while the + // window was hot white). Readback is therefore ALWAYS a plain blit. + let want_tonemap = false; + let _ = lut_view; + + let stale = match world.get::(entity).unwrap().convert { + Some(ref c) => { + c.width != size.width + || c.height != size.height + || matches!(c.mode, ConvertMode::Tonemap { .. }) != want_tonemap + } + None => true, + }; + if stale { + let device = render_device.wgpu_device(); + let tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("async readback convert target"), + size: wgpu::Extent3d { + width: size.width, + height: size.height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: TextureFormat::Rgba8UnormSrgb, + usage: TextureUsages::RENDER_ATTACHMENT | TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); + let mode = if want_tonemap { + build_tonemap_convert(device, lut_view.clone().unwrap()) + } else { + ConvertMode::Blit(wgpu::util::TextureBlitter::new( + device, + TextureFormat::Rgba8UnormSrgb, + )) + }; + world.get_mut::(entity).unwrap().convert = Some(ConvertPass { + texture: tex, + view, + mode, + width: size.width, + height: size.height, + }); + } + world + .get::(entity) + .unwrap() + .convert + .as_ref() + .unwrap() + .texture + .clone() +} + +/// Record the convert (blit or tonemap) into `encoder`, writing the source +/// into the convert pass's sRGB8 target. +fn record_convert( + render_device: &RenderDevice, + encoder: &mut wgpu::CommandEncoder, + convert: &ConvertPass, + src_view: &wgpu::TextureView, +) { + match &convert.mode { + ConvertMode::Blit(blitter) => { + blitter.copy(render_device.wgpu_device(), encoder, src_view, &convert.view); + } + ConvertMode::Tonemap { + pipeline, + bind_group_layout, + lut_view, + lut_sampler, + } => { + let bind_group = render_device + .wgpu_device() + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("readback tonemap convert"), + layout: bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(src_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(lut_view), + }, + wgpu::BindGroupEntry { + binding: 2, + resource: wgpu::BindingResource::Sampler(lut_sampler), + }, + ], + }); + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("readback tonemap convert"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &convert.view, + resolve_target: None, + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &bind_group, &[]); + pass.draw(0..3, 0..1); + } + } +} + +/// Resolve the GPU texture view of bevy's TonyMcMapface LUT, if loaded. +pub fn tonemap_lut_view(app: &mut App) -> Option { + use bevy::core_pipeline::tonemapping::TonemappingLuts; + let lut_id = app + .world() + .get_resource::()? + .tony_mc_mapface + .id(); + let render_world = app.sub_app(RenderApp).world(); + let gpu_images = + render_world.get_resource::>()?; + let img = gpu_images.get(lut_id)?; + Some((*img.texture_view).clone()) +} + +/// Per-graphics async readback state; created lazily on first enqueue. +#[derive(Component, Default)] +pub struct ReadbackRing { + free: Vec<(bevy::render::render_resource::Buffer, u64)>, + pending: std::collections::VecDeque, + convert: Option, +} + +pub fn readback_ring_enqueue( + In((entity, texture, lut_view)): In<(Entity, Texture, Option)>, + world: &mut World, +) -> Result<()> { + let render_device = world.resource::().clone(); + let render_queue = world.resource::().clone(); + let (source_format, size) = { + let graphics = world + .get::(entity) + .ok_or(ProcessingError::GraphicsNotFound)?; + (graphics.texture_format, graphics.size) + }; + // 8-bit surfaces read back as they are; everything else converts on the + // GPU to 8-bit sRGB first (see ConvertPass). + let needs_convert = !matches!( + source_format, + TextureFormat::Rgba8UnormSrgb + | TextureFormat::Rgba8Unorm + | TextureFormat::Bgra8UnormSrgb + | TextureFormat::Bgra8Unorm + ); + let format = if needs_convert { + TextureFormat::Rgba8UnormSrgb + } else { + source_format + }; + let px_size = pixel_size(format)?; + let bytes_per_row = size.width as usize * px_size; + let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(bytes_per_row); + let buffer_size = padded_bytes_per_row as u64 * size.height as u64; + + if world.get::(entity).is_none() { + world.entity_mut(entity).insert(ReadbackRing::default()); + } + { + let ring = world.get::(entity).unwrap(); + if ring.pending.len() >= READBACK_RING_DEPTH { + return Err(ProcessingError::InvalidArgument( + "readback ring full; fetch completed frames first".to_string(), + )); + } + } + + // Reuse a free buffer of the right size; a resize just retires old ones. + let buffer = { + let mut ring = world.get_mut::(entity).unwrap(); + ring.free.retain(|(_, sz)| *sz == buffer_size); + match ring.free.pop() { + Some((b, _)) => b, + None => create_readback_buffer( + &render_device, + size.width, + size.height, + format, + "async readback ring buffer", + )?, + } + }; + + let copy_src = prepare_convert_source( + world, + entity, + &render_device, + &texture, + needs_convert, + size, + &lut_view, + ); + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + if needs_convert { + let src_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let ring = world.get::(entity).unwrap(); + let convert = ring.convert.as_ref().unwrap(); + record_convert(&render_device, &mut encoder, convert, &src_view); + } + encoder.copy_texture_to_buffer( + copy_src.as_image_copy(), + TexelCopyBufferInfo { + buffer: &buffer, + layout: TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some( + std::num::NonZero::::new(padded_bytes_per_row as u32) + .unwrap() + .into(), + ), + rows_per_image: None, + }, + }, + size, + ); + let submission = render_queue.submit(std::iter::once(encoder.finish())); + + let state = std::sync::Arc::new(std::sync::atomic::AtomicU8::new(READBACK_PENDING)); + let state_cb = std::sync::Arc::clone(&state); + buffer.slice(..).map_async(MapMode::Read, move |r| { + // This runs on wgpu's callback thread, so a panic here takes the + // process down from somewhere with no useful context. A mapping that + // aborts is expected during teardown -- the device can be gone before + // the last frame's copy lands -- so record the failure and let the + // fetch side drop the readback. + let next = match r { + Ok(()) => READBACK_READY, + Err(err) => { + debug!("readback buffer mapping aborted, dropping the frame: {err}"); + READBACK_FAILED + } + }; + state_cb.store(next, std::sync::atomic::Ordering::Release); + }); + + world + .get_mut::(entity) + .unwrap() + .pending + .push_back(PendingReadback { + buffer, + state, + submission, + padded_bytes_per_row, + bytes_per_row, + format, + width: size.width, + height: size.height, + }); + Ok(()) +} + +/// Retire the oldest queued readback without reading it. +/// +/// For a readback whose mapping or poll failed -- which is what happens when +/// the device is torn down with a copy still in flight. The buffer is dropped +/// rather than recycled onto the free list, since wgpu still regards it as +/// mapped and handing it back out would fail again on its next use. +fn drop_failed_readback(world: &mut World, entity: Entity) { + if let Some(mut ring) = world.get_mut::(entity) { + ring.pending.pop_front(); + } +} + +/// Number of enqueued readbacks not yet fetched. +pub fn readback_ring_pending(In(entity): In, world: &mut World) -> Result { + Ok(world + .get::(entity) + .map(|r| r.pending.len()) + .unwrap_or(0)) +} + +/// Fetch the oldest enqueued readback if (or, blocking, once) its copy has +/// completed. Returns `None` when nothing is pending, or when non-blocking +/// and the oldest copy is still in flight. +pub fn readback_ring_fetch( + In((entity, blocking)): In<(Entity, bool)>, + world: &mut World, +) -> Result> { + let render_device = world.resource::().clone(); + { + let Some(ring) = world.get::(entity) else { + return Ok(None); + }; + if ring.pending.is_empty() { + return Ok(None); + } + } + + // Pump the device so map_async callbacks can fire; block only if asked, + // and then only until THIS copy's submission completes — never the whole + // queue. + loop { + let (state, submission) = { + let ring = world.get::(entity).unwrap(); + let front = ring.pending.front().unwrap(); + ( + front.state.load(std::sync::atomic::Ordering::Acquire), + front.submission.clone(), + ) + }; + if state == READBACK_READY { + break; + } + // A mapping that aborted is never coming back, so retire the entry + // rather than waiting on it forever. Its buffer is deliberately NOT + // returned to the free list: it is still considered mapped by wgpu and + // reusing it would be a second failure. + if state == READBACK_FAILED { + drop_failed_readback(world, entity); + return Ok(None); + } + // Polling can itself fail once the device is gone, which is the same + // teardown story -- drop the frame instead of taking the process with + // it. + let polled = if blocking { + render_device.poll(PollType::Wait { + submission_index: Some(submission), + timeout: None, + }) + } else { + render_device.poll(PollType::Poll) + }; + if let Err(err) = polled { + debug!("polling for a readback failed, dropping the frame: {err}"); + drop_failed_readback(world, entity); + return Ok(None); + } + if !blocking { + let state_now = { + let ring = world.get::(entity).unwrap(); + ring.pending + .front() + .unwrap() + .state + .load(std::sync::atomic::Ordering::Acquire) + }; + if state_now == READBACK_PENDING { + return Ok(None); + } + } + } + + let pr = world + .get_mut::(entity) + .unwrap() + .pending + .pop_front() + .unwrap(); + let Ok(mapped) = pr.buffer.slice(..).get_mapped_range() else { + debug!("readback buffer went away before it could be read, dropping the frame"); + return Ok(None); + }; + let data = mapped.to_vec(); + drop(mapped); + pr.buffer.unmap(); + + let unpadded = if pr.padded_bytes_per_row != pr.bytes_per_row { + data.chunks_exact(pr.padded_bytes_per_row) + .take(pr.height as usize) + .flat_map(|row| &row[..pr.bytes_per_row]) + .copied() + .collect() + } else { + data + }; + + let buffer_size = pr.padded_bytes_per_row as u64 * pr.height as u64; + world + .get_mut::(entity) + .unwrap() + .free + .push((pr.buffer, buffer_size)); + + Ok(Some(ReadbackData { + bytes: unpadded, + format: pr.format, + width: pr.width, + height: pr.height, + })) +} + pub fn update_region_write( In((entity, texture, x, y, width, height, data, px_size)): In<( Entity, diff --git a/crates/processing_render/src/image.rs b/crates/processing_render/src/image.rs index e7a4fcd7..3bd40771 100644 --- a/crates/processing_render/src/image.rs +++ b/crates/processing_render/src/image.rs @@ -100,6 +100,14 @@ pub fn blit( Ok(()) } +#[derive(Component)] +#[relationship(relationship_target = LinkedImageTarget)] +pub struct LinkedImage(pub Entity); + +#[derive(Component)] +#[relationship_target(relationship = LinkedImage)] +pub struct LinkedImageTarget(Entity); + #[derive(Component)] pub struct Image { pub handle: Handle, @@ -145,6 +153,174 @@ pub fn create( .id() } +/// Create a 3D texture usable as a storage texture in compute kernels +/// (`texture_storage_3d`) and as a sampled `texture_3d` in +/// materials/filters. Zero-initialized; trilinear clamp-to-edge sampler. +/// +/// Unlike 2D images this is never a render attachment, and the CPU-side asset +/// copy is dropped after upload (a 256^3 field is 32MB — keeping a main-world +/// mirror would double it). Readback goes through `readback_3d_raw`. +pub fn create_3d( + In((size, texture_format)): In<(Extent3d, TextureFormat)>, + mut commands: Commands, + mut images: ResMut>, + render_device: Res, +) -> Result { + let px_size = pixel_size(texture_format)?; + let data = vec![ + 0u8; + size.width as usize + * size.height as usize + * size.depth_or_array_layers as usize + * px_size + ]; + let mut image = bevy::image::Image::new( + size, + TextureDimension::D3, + data, + texture_format, + RenderAssetUsages::RENDER_WORLD, + ); + image.texture_descriptor.usage = TextureUsages::TEXTURE_BINDING + | TextureUsages::STORAGE_BINDING + | TextureUsages::COPY_DST + | TextureUsages::COPY_SRC; + image.sampler = ImageSampler::Descriptor(ImageSamplerDescriptor { + mag_filter: ImageFilterMode::Linear, + min_filter: ImageFilterMode::Linear, + mipmap_filter: ImageFilterMode::Linear, + address_mode_u: ImageAddressMode::ClampToEdge, + address_mode_v: ImageAddressMode::ClampToEdge, + address_mode_w: ImageAddressMode::ClampToEdge, + ..Default::default() + }); + + let handle = images.add(image); + let readback_buffer = create_readback_buffer_3d( + &render_device, + size.width, + size.height, + size.depth_or_array_layers, + texture_format, + "Texture3D Readback Buffer", + )?; + + Ok(commands + .spawn(Image { + handle, + readback_buffer, + texture_format, + size, + }) + .id()) +} + +/// Upload a full volume of tightly-packed texel bytes into a 3D texture. +pub fn write_3d_raw( + In((entity, texture, data)): In<(Entity, Texture, Vec)>, + p_images: Query<&Image>, + render_queue: Res, +) -> Result<()> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + let px_size = pixel_size(p_image.texture_format)? as u32; + let expected = (p_image.size.width + * p_image.size.height + * p_image.size.depth_or_array_layers + * px_size) as usize; + if data.len() != expected { + return Err(ProcessingError::InvalidArgument(format!( + "texture3d write: expected {} bytes for {}x{}x{} {:?}, got {}", + expected, + p_image.size.width, + p_image.size.height, + p_image.size.depth_or_array_layers, + p_image.texture_format, + data.len() + ))); + } + + render_queue.write_texture( + TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: Origin3d::ZERO, + aspect: Default::default(), + }, + &data, + TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(p_image.size.width * px_size), + rows_per_image: Some(p_image.size.height), + }, + p_image.size, + ); + + Ok(()) +} + +/// Read a full 3D texture back as tightly-packed texel bytes (row padding from +/// the copy alignment is stripped). +pub fn readback_3d_raw( + In((entity, texture)): In<(Entity, Texture)>, + p_images: Query<&Image>, + render_device: Res, + render_queue: ResMut, +) -> Result> { + let p_image = p_images + .get(entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + + let px_size = pixel_size(p_image.texture_format)?; + let padded_bytes_per_row = + RenderDevice::align_copy_bytes_per_row(p_image.size.width as usize * px_size); + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + encoder.copy_texture_to_buffer( + texture.as_image_copy(), + TexelCopyBufferInfo { + buffer: &p_image.readback_buffer, + layout: TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some( + std::num::NonZero::::new(padded_bytes_per_row as u32) + .unwrap() + .into(), + ), + rows_per_image: Some(p_image.size.height), + }, + }, + p_image.size, + ); + render_queue.submit(std::iter::once(encoder.finish())); + + let buffer_slice = p_image.readback_buffer.slice(..); + let (s, r) = crossbeam_channel::bounded(1); + buffer_slice.map_async(MapMode::Read, move |res| match res { + Ok(res) => s.send(res).expect("Failed to send map update"), + Err(err) => panic!("Failed to map buffer {err}"), + }); + render_device + .poll(PollType::wait_indefinitely()) + .expect("Failed to poll device for map async"); + r.recv().expect("Failed to receive the map_async message"); + + let padded = buffer_slice + .get_mapped_range() + .expect("Failed to get mapped range for readback") + .to_vec(); + p_image.readback_buffer.unmap(); + + let bytes_per_row = p_image.size.width as usize * px_size; + let rows = (p_image.size.height * p_image.size.depth_or_array_layers) as usize; + let mut out = Vec::with_capacity(bytes_per_row * rows); + for row in padded.chunks_exact(padded_bytes_per_row).take(rows) { + out.extend_from_slice(&row[..bytes_per_row]); + } + Ok(out) +} + pub fn load_start(world: &mut World, path: PathBuf) -> Handle { world.get_asset_server().load(path) } @@ -302,7 +478,10 @@ pub fn readback( r.recv().expect("Failed to receive the map_async message"); - let data = buffer_slice.get_mapped_range().to_vec(); + let data = buffer_slice + .get_mapped_range() + .expect("Failed to get mapped range for readback") + .to_vec(); let mut image = images .get_mut(&p_image.handle) @@ -421,6 +600,8 @@ pub fn destroy( /// Get the size in bytes of a single pixel for the given texture format. pub fn pixel_size(format: TextureFormat) -> Result { match format { + TextureFormat::R16Float => Ok(2), + TextureFormat::R32Float | TextureFormat::R32Uint | TextureFormat::Rg16Float => Ok(4), TextureFormat::Rgba8Unorm | TextureFormat::Rgba8UnormSrgb => Ok(4), TextureFormat::Rgba16Float => Ok(8), TextureFormat::Rgba32Float => Ok(16), @@ -428,6 +609,27 @@ pub fn pixel_size(format: TextureFormat) -> Result { } } +/// Create a readback buffer sized for a (possibly 3D) texture. +pub fn create_readback_buffer_3d( + render_device: &RenderDevice, + width: u32, + height: u32, + depth: u32, + format: TextureFormat, + label: &str, +) -> Result { + let px_size = pixel_size(format)?; + let padded_bytes_per_row = RenderDevice::align_copy_bytes_per_row(width as usize * px_size); + let buffer_size = padded_bytes_per_row as u64 * height as u64 * depth as u64; + + Ok(render_device.create_buffer(&BufferDescriptor { + label: Some(label), + size: buffer_size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + })) +} + /// Convert LinearRgba pixels to raw bytes in the specified texture format. pub fn pixels_to_bytes(pixels: &[LinearRgba], format: TextureFormat) -> Result> { match format { diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 6359c86f..34e563de 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -1,6 +1,10 @@ #![allow(clippy::module_inception)] +pub mod animation; pub mod camera; +/// GPU-resident frame capture into IOSurfaces; macOS only. +#[cfg(target_os = "macos")] +pub mod capture_macos; pub mod color; pub mod compute; pub mod geometry; @@ -35,17 +39,19 @@ pub use particles::{ 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_primitives_apply, particles_primitives_attempted, particles_primitives_create, + particles_primitives_destroy, particles_primitives_field, 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, + particles_emit_gpu, particles_ensure_attribute, particles_flock, particles_flock_auto, + 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; @@ -71,7 +77,7 @@ pub struct ProcessingRenderPlugin; impl Plugin for ProcessingRenderPlugin { fn build(&self, app: &mut App) { - use render::material::{add_custom_materials, add_processing_materials}; + use render::material::sync_typed_materials; use render::{activate_cameras, clear_transient_meshes, flush_draw_commands}; let config = app.world().resource::().clone(); @@ -102,16 +108,12 @@ impl Plugin for ProcessingRenderPlugin { bevy::camera_controller::pan_camera::PanCameraPlugin, text::font::TextPlugin, )); + app.add_plugins(animation::AnimationPoolPlugin); app.add_systems(First, (clear_transient_meshes, activate_cameras)) .add_systems( Update, - ( - flush_draw_commands, - add_processing_materials, - add_custom_materials, - particles::material::add_particles_materials, - ) + (flush_draw_commands, sync_typed_materials) .chain() .before(AssetEventSystems), ); @@ -374,6 +376,14 @@ pub fn surface_resize(graphics_entity: Entity, width: u32, height: u32) -> error }) } +pub fn surface_set_vsync(entity: Entity, vsync: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(surface::set_vsync, (entity, vsync)) + .unwrap() + }) +} + pub fn surface_set_pixel_density(entity: Entity, density: f32) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -520,13 +530,26 @@ pub fn graphics_create( width: u32, height: u32, texture_format: TextureFormat, +) -> error::Result { + graphics_create_ex(surface_entity, width, height, texture_format, false) +} + +/// Create a graphics context, optionally as a STATE canvas: a +/// post-process-free, linear-color float target for feedback/simulation +/// patches (see [`graphics::StateCanvas`]). +pub fn graphics_create_ex( + surface_entity: Entity, + width: u32, + height: u32, + texture_format: TextureFormat, + state: bool, ) -> error::Result { app_mut(|app| -> error::Result { let entity = app .world_mut() .run_system_cached_with( graphics::create, - (width, height, surface_entity, texture_format), + (width, height, surface_entity, texture_format, state), ) .unwrap()?; @@ -559,6 +582,21 @@ pub fn graphics_end_draw(graphics_entity: Entity) -> error::Result<()> { app_mut(|app| graphics::end_draw(app, graphics_entity)) } +/// Whether a canvas has draw commands waiting for a flush. +pub fn graphics_has_pending(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::has_pending, graphics_entity) + .unwrap() + }) +} + +/// End a draw without forcing an update: the canvas is armed to present on +/// the next one. See [`graphics::arm_present`]. +pub fn graphics_arm_present(graphics_entity: Entity) -> error::Result<()> { + app_mut(|app| graphics::arm_present(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)) @@ -658,8 +696,123 @@ pub fn graphics_readback_raw(graphics_entity: Entity) -> error::Result error::Result<()> { + app_mut(|app| { + graphics::flush(app, graphics_entity)?; + let vt = graphics::view_target(app, graphics_entity)?; + let texture = vt.main_texture().clone(); + let lut_view = graphics::tonemap_lut_view(app); + app.world_mut() + .run_system_cached_with( + graphics::readback_ring_enqueue, + (graphics_entity, texture, lut_view), + ) + .unwrap() + }) +} + +/// Fetch the oldest completed asynchronous readback, in submission order. +/// Non-blocking unless `blocking`; `None` when nothing is ready/pending. +pub fn graphics_readback_fetch( + graphics_entity: Entity, + blocking: bool, +) -> error::Result> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::readback_ring_fetch, (graphics_entity, blocking)) + .unwrap() + }) +} + +/// Asynchronous readbacks submitted but not yet fetched. +pub fn graphics_readback_pending(graphics_entity: Entity) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::readback_ring_pending, graphics_entity) + .unwrap() + }) +} + +/// Non-blocking device poll: pumps wgpu so `map_async` and +/// `on_submitted_work_done` callbacks can fire while a caller waits on GPU +/// completions outside the render loop. +pub fn graphics_device_poll() -> error::Result<()> { + app_mut(|app| { + let device = app + .world() + .resource::() + .clone(); + let _ = device.poll(bevy::render::render_resource::PollType::Poll); + Ok(()) + }) +} + +/// Current pixel size of the graphics surface. +pub fn graphics_surface_size(graphics_entity: Entity) -> error::Result<(u32, u32)> { + app_mut(|app| { + let graphics = app + .world() + .get::(graphics_entity) + .ok_or(error::ProcessingError::GraphicsNotFound)?; + Ok((graphics.size.width, graphics.size.height)) + }) +} + +/// Flush pending draws, blit the canvas into the given IOSurface on the GPU, +/// and fire `on_done` (from wgpu's callback thread) once the blit completes. +/// The GPU-resident recording path; see `capture_macos`. +#[cfg(target_os = "macos")] +pub fn graphics_capture_into_iosurface( + graphics_entity: Entity, + iosurface: *mut std::ffi::c_void, + iosurface_id: u32, + on_done: capture_macos::CaptureDone, +) -> error::Result<()> { + let iosurface = iosurface as usize; + app_mut(|app| { + // Capture runs right after endDraw, which already flushed and + // rendered — an unconditional flush here would pump a whole extra + // app.update() (~3ms of fixed cost) per recorded frame for nothing. + let pending = app + .world_mut() + .run_system_cached_with(graphics::has_pending, graphics_entity) + .unwrap()?; + if pending { + graphics::flush(app, graphics_entity)?; + } + let vt = graphics::view_target(app, graphics_entity)?; + let texture = vt.main_texture().clone(); app.world_mut() - .run_system_cached_with(graphics::readback_raw, (graphics_entity, texture)) + .run_system_cached_with( + capture_macos::capture_into_surface, + (graphics_entity, texture, iosurface, iosurface_id, on_done), + ) + .unwrap() + }) +} + +/// Drop the GPU capture cache for a graphics (end of a recording). +#[cfg(target_os = "macos")] +pub fn graphics_capture_reset(graphics_entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(capture_macos::capture_reset, graphics_entity) .unwrap() }) } @@ -930,6 +1083,77 @@ pub fn graphics_ortho( }) } +/// glFrustum-style asymmetric perspective projection; the bounds are on the +/// near plane, matching classic Processing/OpenGL `frustum()`. +pub fn graphics_frustum( + graphics_entity: Entity, + left: f32, + right: f32, + bottom: f32, + top: f32, + near: f32, + far: f32, +) -> error::Result<()> { + app_mut(|app| { + flush(app, graphics_entity)?; + app.world_mut() + .run_system_cached_with( + graphics::frustum, + ( + graphics_entity, + graphics::FrustumArgs { + left, + right, + bottom, + top, + near, + far, + }, + ), + ) + .unwrap() + }) +} + +/// Spawn an animation pose pool: K paused puppet instances of the glTF's +/// scene, seeked along `clip_name` at k/K, whose joint matrices feed skinned +/// particle instancing. Returns the pool entity. +pub fn gltf_animation_pool(gltf_entity: Entity, clip_name: &str, k: u32) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + animation::pool_create, + (gltf_entity, clip_name.to_string(), k), + ) + .unwrap() + }) +} + +pub fn gltf_animation_pool_destroy(pool_entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(animation::pool_destroy, pool_entity) + .unwrap() + }) +} + +/// Attach a pose pool + per-particle phase attribute to a particle system; +/// its pack dispatch switches to the skin-enabled variant. +pub fn particles_set_skin_pool( + particles_entity: Entity, + pool_entity: Entity, + phase_attribute: Entity, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + particles::set_skin_pool, + (particles_entity, pool_entity, phase_attribute), + ) + .unwrap() + }) +} + pub fn graphics_world_from_screen( graphics_entity: Entity, sx: f32, @@ -1107,6 +1331,56 @@ pub fn image_create( }) } +/// Create a 3D texture (compute-writable storage texture + trilinear-sampled +/// `texture_3d`), zero-initialized. +pub fn texture3d_create(size: Extent3d, texture_format: TextureFormat) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(image::create_3d, (size, texture_format)) + .unwrap()?; + // Prepare the GpuImage now so the texture can be bound (compute + // dispatch, material prepare) or written this same frame. + app.update(); + Ok(entity) + }) +} + +/// The wgpu texture for an image entity, pumping one update if the GPU asset +/// has not been prepared yet. +fn image_texture( + app: &mut App, + entity: Entity, +) -> error::Result { + match gpu_image(app, entity) { + Ok(img) => Ok(img.texture.clone()), + Err(_) => { + app.update(); + Ok(gpu_image(app, entity)?.texture.clone()) + } + } +} + +/// Upload a full volume of tightly-packed texel bytes into a 3D texture. +pub fn texture3d_write(entity: Entity, data: Vec) -> error::Result<()> { + app_mut(|app| { + let texture = image_texture(app, entity)?; + app.world_mut() + .run_system_cached_with(image::write_3d_raw, (entity, texture, data)) + .unwrap() + }) +} + +/// Read a full 3D texture back as tightly-packed texel bytes. +pub fn texture3d_readback(entity: Entity) -> error::Result> { + app_mut(|app| { + let texture = image_texture(app, entity)?; + app.world_mut() + .run_system_cached_with(image::readback_3d_raw, (entity, texture)) + .unwrap() + }) +} + /// Load an image from disk. #[cfg(not(target_arch = "wasm32"))] pub fn image_load(path: &str) -> error::Result { @@ -1287,6 +1561,98 @@ pub fn image_destroy(entity: Entity) -> error::Result<()> { }) } +/// Destroy a light. +pub fn light_destroy(light_entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(light::destroy, light_entity) + .unwrap() + }) +} + +/// Aim a light along a travel direction (Processing directionalLight +/// convention). +pub fn light_set_direction(light_entity: Entity, dir: Vec3) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(light::set_direction, (light_entity, dir)) + .unwrap() + }) +} + +/// Toggle shadow casting on a directional light. +pub fn light_set_shadows(light_entity: Entity, enabled: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(light::set_shadows, (light_entity, enabled)) + .unwrap() + }) +} + +/// Toggle contact-shadow casting on a directional light. +pub fn light_set_contact_shadows(light_entity: Entity, enabled: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(light::set_contact_shadows, (light_entity, enabled)) + .unwrap() + }) +} + +/// Set a directional light's shadow-map depth and normal bias. +pub fn light_set_shadow_bias( + light_entity: Entity, + depth_bias: f32, + normal_bias: f32, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + light::set_shadow_bias, + (light_entity, depth_bias, normal_bias), + ) + .unwrap() + }) +} + +/// Turn screen-space contact shadows on or off for a canvas' camera. +/// +/// Checks that bevy's `ContactShadowsPlugin` is actually in the app rather +/// than assuming it rides in with `PbrPlugin`: it does today, but a plugin +/// that is not registered would leave this silently inserting a component +/// nothing reads, which is exactly the kind of quiet no-op that costs an +/// afternoon. +pub fn graphics_set_contact_shadows( + graphics_entity: Entity, + enabled: bool, + linear_steps: u32, + thickness: f32, + length: f32, +) -> error::Result<()> { + app_mut(|app| { + if enabled && !app.is_plugin_added::() { + bevy::log::warn_once!( + "contact shadows were requested but ContactShadowsPlugin is not registered in \ + this app, so nothing will change on screen." + ); + } + app.world_mut() + .run_system_cached_with( + graphics::set_contact_shadows, + (graphics_entity, enabled, linear_steps, thickness, length), + ) + .unwrap() + }) +} + +/// Turn temporal antialiasing on or off for a canvas' camera. +pub fn graphics_set_temporal_aa(graphics_entity: Entity, enabled: bool) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(graphics::set_temporal_aa, (graphics_entity, enabled)) + .unwrap() + }) +} + pub fn light_create_directional( graphics_entity: Entity, color: Color, @@ -1901,24 +2267,29 @@ fn material_set_particles_buffer( return Err(error::ProcessingError::MaterialNotFound); }; let world = app.world_mut(); - let preserved = { + let (preserved, prev_blend_state, prev_depth_write) = { let mut mats = world.resource_mut::>(); - let base = mats + let mat = mats .get(&handle) - .ok_or(error::ProcessingError::MaterialNotFound)? - .base - .clone(); + .ok_or(error::ProcessingError::MaterialNotFound)?; + let base = mat.base.clone(); + let blend_state = mat.extension.blend_state; + let depth_write = mat.extension.depth_write; mats.remove(&handle); - base + (base, blend_state, depth_write) }; let extension = match slot { ParticlesBufferSlot::Albedo => ParticlesExtension { colors: Some(buffer_handle), emissive_colors: None, + blend_state: prev_blend_state, + depth_write: prev_depth_write, }, ParticlesBufferSlot::Emissive => ParticlesExtension { colors: None, emissive_colors: Some(buffer_handle), + blend_state: prev_blend_state, + depth_write: prev_depth_write, }, }; let new_handle = world @@ -1996,6 +2367,14 @@ pub fn material_set_depth_write(entity: Entity, value: bool) -> error::Result<() }) } +pub fn material_set_depth_bias(entity: Entity, value: f32) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(material::set_depth_bias, (entity, value)) + .unwrap() + }) +} + pub fn material_set_custom_blend( entity: Entity, blend: bevy::render::render_resource::BlendState, @@ -2180,6 +2559,14 @@ pub fn gltf_load(graphics_entity: Entity, path: &str) -> error::Result { }) } +pub fn gltf_destroy(gltf_entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(gltf::destroy, gltf_entity) + .unwrap() + }) +} + #[cfg(not(target_arch = "wasm32"))] pub fn gltf_geometry(gltf_entity: Entity, name: &str) -> error::Result { app_mut(|app| { @@ -2453,31 +2840,75 @@ pub fn compute_set( pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { app_mut(|app| { + if std::env::var("PROCESSING_TRACE_VT").is_ok() { eprintln!("ENGINE dispatch(update first)"); } app.update(); + dispatch_inner(app, entity, x, y, z) + }) +} - 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, - ) +/// Dispatch without pumping a full `app.update()` first. Multi-pass GPU +/// algorithms (grid build, prefix sum) and per-frame kernel chains issue +/// many dispatches back to back; running the entire app schedule between +/// each one costs an engine frame per dispatch and dwarfs the GPU work. +/// Queue submission order already guarantees pass ordering. The one thing +/// `update()` provides that a dispatch may genuinely need is pipeline +/// compilation on first use — so on `PipelineNotReady` we pump once and +/// retry. +/// +/// Public since it is also the user-facing fast path for dispatch chains: +/// callers opt out of the "resources created earlier this frame are always +/// visible" guarantee that [`compute_dispatch`] buys with its update — a +/// buffer or texture created since the last update may bind as unresolved. +/// Set everything up, then chain quiet dispatches. +pub fn compute_dispatch_quiet( + entity: Entity, + x: u32, + y: u32, + z: u32, +) -> error::Result<()> { + app_mut(|app| { + let result = match dispatch_inner(app, entity, x, y, z) { + Err(error::ProcessingError::PipelineNotReady(_)) => { + app.update(); + dispatch_inner(app, entity, x, y, z) + } + r => r, }; - app.sub_app_mut(bevy::render::RenderApp) - .world_mut() - .run_system_cached_with(compute::dispatch, args) - .unwrap() + // `invalidate_rw_buffers` normally runs in the main schedule's `Last`, + // which a quiet dispatch skips — without this a CPU readback after a + // quiet chain returns the stale synced copy instead of the GPU data. + if result.is_ok() { + app.world_mut() + .run_system_cached(compute::invalidate_rw_buffers) + .unwrap(); + } + result }) } +fn dispatch_inner(app: &mut App, entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { + 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() @@ -2541,6 +2972,18 @@ pub fn font_create(name: &str) -> error::Result { }) } +/// Destroy a font handle entity. Registered font data remains cached in the +/// process-wide text context and may be reused by another handle. +pub fn font_destroy(font_entity: Entity) -> error::Result<()> { + app_mut(|app| { + if app.world().get::(font_entity).is_none() { + return Err(error::ProcessingError::InvalidEntity); + } + app.world_mut().entity_mut(font_entity).despawn(); + Ok(()) + }) +} + /// List all available font family names (system + registered). pub fn font_list() -> error::Result> { use text::font::TextContext; diff --git a/crates/processing_render/src/light.rs b/crates/processing_render/src/light.rs index 423703c2..7c76f9c5 100644 --- a/crates/processing_render/src/light.rs +++ b/crates/processing_render/src/light.rs @@ -5,12 +5,99 @@ use bevy::{camera::visibility::RenderLayers, prelude::*}; use crate::{error::ProcessingError, graphics::Graphics}; +/// Calibration: the engine's light contract is PHYSICAL LUX at the default +/// camera exposure (EV100 9.7, Blender's default — bevy's own convention). +/// +/// The current bevy-tib-rebase vintage renders analytical (directional/point/ +/// spot) lights ~1/exposure(9.7)^2 = 9.2e5x hotter than physical: measured on +/// the grey-card probe (quil `dev/greycard_once.clj`, linear-pipeline fits +/// kappa = 7.5e5..9.6e5 across half a decade of lux), while ambient light, +/// emissive, and the camera `Exposure` knob itself all behave correctly (the +/// exposure law verified exact over 10 EV of camera exposure). Scaling +/// illuminance/intensity by exposure(9.7)^2 at creation restores the physical +/// contract without touching the (correct) ambient and emissive paths. +/// Re-verify with the probe after any bevy rebase; if upstream fixes its +/// light scaling this constant must be re-measured or the scene goes dark. +const LIGHT_EXPOSURE_COMPENSATION: f32 = 1.0828123e-6; // (exp2(-9.7) / 1.2)^2 + pub struct LightPlugin; impl Plugin for LightPlugin { fn build(&self, _app: &mut App) {} } +/// Despawn a light entity (both retained-API and immediate-mode lights). +pub fn destroy(In(entity): In, mut commands: Commands) -> Result<(), ProcessingError> { + commands.entity(entity).despawn(); + Ok(()) +} + +/// Aim a light: `dir` is the direction the light TRAVELS (Processing's +/// directionalLight convention). Distinct from the generic Euler-angle +/// transform setter, which cannot express "point this way". +pub fn set_direction( + In((entity, dir)): In<(Entity, Vec3)>, + mut transforms: Query<&mut Transform>, +) -> Result<(), ProcessingError> { + let mut transform = transforms + .get_mut(entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let dir = dir.try_normalize().ok_or(ProcessingError::InvalidArgument( + "light direction must be non-zero".into(), + ))?; + // Lights shine along their -Z; look_to aims -Z at `dir`. + let up = if dir.y.abs() > 0.99 { Vec3::Z } else { Vec3::Y }; + transform.look_to(dir, up); + Ok(()) +} + +/// Toggle shadow casting on a directional light. +pub fn set_shadows( + In((entity, enabled)): In<(Entity, bool)>, + mut lights: Query<&mut DirectionalLight>, +) -> Result<(), ProcessingError> { + let mut light = lights + .get_mut(entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + light.shadow_maps_enabled = enabled; + Ok(()) +} + +/// Toggle contact-shadow casting on a directional light. +/// +/// Separate from `set_shadows`: shadow maps and contact shadows are +/// independent gates on the same light, and the effect also has to be enabled +/// on the camera (`graphics::set_contact_shadows`) before a light's flag does +/// anything. +pub fn set_contact_shadows( + In((entity, enabled)): In<(Entity, bool)>, + mut lights: Query<&mut DirectionalLight>, +) -> Result<(), ProcessingError> { + let mut light = lights + .get_mut(entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + light.contact_shadows_enabled = enabled; + Ok(()) +} + +/// Set the shadow-map depth and normal bias for a directional light. +/// +/// These are the two knobs for shadow acne -- the self-shadowing stipple a +/// surface gets when its own depth in the shadow map lands ambiguously. +/// Raising them trades acne for peter-panning (shadows detaching from their +/// caster). A stack of coplanar faces is the scene that needs them. +pub fn set_shadow_bias( + In((entity, depth_bias, normal_bias)): In<(Entity, f32, f32)>, + mut lights: Query<&mut DirectionalLight>, +) -> Result<(), ProcessingError> { + let mut light = lights + .get_mut(entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + light.shadow_depth_bias = depth_bias; + light.shadow_normal_bias = normal_bias; + Ok(()) +} + pub fn create_directional( In((entity, color, illuminance)): In<(Entity, Color, f32)>, mut commands: Commands, @@ -22,7 +109,7 @@ pub fn create_directional( Ok(commands .spawn(( DirectionalLight { - illuminance, + illuminance: illuminance * LIGHT_EXPOSURE_COMPENSATION, color, ..default() }, @@ -42,7 +129,7 @@ pub fn create_point( Ok(commands .spawn(( PointLight { - intensity, + intensity: intensity * LIGHT_EXPOSURE_COMPENSATION, color, range, radius, @@ -73,7 +160,7 @@ pub fn create_spot( .spawn(( SpotLight { color, - intensity, + intensity: intensity * LIGHT_EXPOSURE_COMPENSATION, range, radius, inner_angle, diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index f39de9d7..4209480d 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -11,7 +11,7 @@ wesl::wesl_pkg!(lygia); use bevy::{ asset::{AsAssetId, AssetEventSystems}, - core_pipeline::core_3d::Opaque3d, + core_pipeline::core_3d::{AlphaMask3d, Opaque3d, Transparent3d}, ecs::system::{ SystemParamItem, lifetimeless::{SRes, SResMut}, @@ -24,10 +24,10 @@ use bevy::{ }, mesh::MeshVertexBufferLayoutRef, pbr::{ - DrawMaterial, EntitiesNeedingSpecialization, MainPassOpaqueDrawFunction, - MainPassTransparentDrawFunction, MaterialBindGroupAllocator, MaterialBindGroupAllocators, - MaterialFragmentShader, MaterialVertexShader, MeshPipelineKey, PreparedMaterial, - RenderMaterialBindings, RenderMaterialInstance, RenderMaterialInstances, base_specialize, + DrawMaterial, EntitiesNeedingSpecialization, MainPassAlphaMaskDrawFunction, + MainPassOpaqueDrawFunction, MainPassTransparentDrawFunction, MaterialFragmentShader, + MaterialVertexShader, MeshPipelineKey, PreparedMaterial, RenderMaterialInstance, + RenderMaterialInstances, base_specialize, }, prelude::*, reflect::{PartialReflect, ReflectMut, ReflectRef, structs::Struct}, @@ -35,6 +35,9 @@ use bevy::{ Extract, RenderApp, RenderStartup, camera::{DirtySpecializationSystems, DirtySpecializations}, erased_render_asset::{ErasedRenderAsset, ErasedRenderAssetPlugin, PrepareAssetError}, + material_bind_groups::{ + MaterialBindGroupAllocator, MaterialBindGroupAllocators, RenderMaterialBindings, + }, render_asset::RenderAssets, render_phase::DrawFunctions, render_resource::{ @@ -88,6 +91,7 @@ pub struct CustomMaterial { pub alpha_mode: AlphaMode, pub double_sided: Option, pub depth_write: Option, + pub depth_bias: f32, } #[derive(Component)] @@ -302,6 +306,7 @@ pub fn create_custom( alpha_mode: AlphaMode::Opaque, double_sided: None, depth_write: None, + depth_bias: 0.0, }; let handle = custom_materials.add(material); Ok(commands.spawn(UntypedMaterial(handle.untyped())).id()) @@ -573,6 +578,8 @@ impl ErasedRenderAsset for CustomMaterial { type ErasedAsset = PreparedMaterial; type Param = ( SRes>, + SRes>, + SRes>, SRes, SRes, SResMut, @@ -586,6 +593,8 @@ impl ErasedRenderAsset for CustomMaterial { asset_id: AssetId, ( opaque_draw_functions, + alpha_mask_draw_functions, + transparent_draw_functions, _asset_server, render_device, bind_group_allocators, @@ -608,6 +617,54 @@ impl ErasedRenderAsset for CustomMaterial { gpu_buffers, ); + // Every binding the reflected LAYOUT declares must be present in the + // bind group. `create_bindings` skips a storage parameter whose buffer + // isn't resolvable yet (unset, or its GpuShaderBuffer not prepared in + // this frame), which would otherwise hand wgpu a short descriptor — + // "Number of bindings in bind group descriptor (1) does not match the + // number of bindings defined in the bind group layout (3)" — and that + // is a validation error, i.e. it kills the app. A material with two + // storage buffers hit this every time. Retry instead: a + // not-yet-prepared buffer resolves on a later frame, and a genuinely + // unset one gets a named warning rather than a crash. + if bindings.len() != layout_entries.len() { + let bound: Vec = bindings.iter().map(|(b, _)| *b).collect(); + let missing: Vec = layout_entries + .iter() + .map(|e| e.binding) + .filter(|b| !bound.contains(b)) + .collect(); + let names: Vec = reflection + .parameters() + .filter(|p| missing.contains(&p.binding())) + .filter_map(|p| p.name().map(|n| n.to_string())) + .collect(); + // Diagnose WHICH half is missing: no handle on the extracted + // material (assignment hasn't reached the render world) vs handle + // present but its GpuShaderBuffer not prepared yet (asset race). + let detail: Vec = names + .iter() + .map(|n| match source_asset.shader.buffer_handle(n) { + None => format!("{n}: NO HANDLE on extracted material"), + Some(h) => match gpu_buffers.get(h) { + None => format!("{n}: handle present, GPU BUFFER NOT PREPARED"), + Some(_) => format!("{n}: handle+gpu present (unexpected)"), + }, + }) + .collect(); + warn!( + "custom material: {} of {} @group(3) bindings unresolved {:?} \ + (unbound: {:?}) — retrying next frame. If this repeats, the \ + shader declares a storage buffer that was never assigned; \ + set it with set_uniforms/set-uniforms!.", + missing.len(), + layout_entries.len(), + detail, + missing, + ); + return Err(PrepareAssetError::RetryNextUpdate(source_asset)); + } + let unprepared = UnpreparedBindGroup { bindings: BindingResources(bindings), }; @@ -628,9 +685,34 @@ impl ErasedRenderAsset for CustomMaterial { .insert(bind_group_allocator.allocate_unprepared(unprepared, &bind_group_layout)), }; - let draw_function = opaque_draw_functions.read().id::(); + // A `DrawFunctionId` is an index into a PER-PHASE registry — each + // phase label must carry its own registry's id, never a reused one. + let draw_opaque = opaque_draw_functions.read().id::(); + let draw_alpha_mask = alpha_mask_draw_functions.read().id::(); + let draw_transparent = transparent_draw_functions.read().id::(); let blend_state = source_asset.blend_state; + // A custom blend forces the sorted transparent phase (an arbitrary + // blend equation can't be assumed commutative); otherwise honor the + // explicitly-set alpha mode. + let alpha_mode = if blend_state.is_some() { + AlphaMode::Blend + } else { + source_asset.alpha_mode + }; + // The queue phase must follow the effective alpha mode (as + // bevy_pbr's Material impl derives it) — a Blend material left in + // the default Opaque binned phase draws before Transparent3d and is + // erased by the background quad, which is itself a Transparent3d + // REPLACE draw. + let render_phase_type = match alpha_mode { + AlphaMode::Blend + | AlphaMode::Premultiplied + | AlphaMode::Add + | AlphaMode::Multiply => bevy::material::RenderPhaseType::Transparent, + AlphaMode::Mask(_) => bevy::material::RenderPhaseType::AlphaMask, + _ => bevy::material::RenderPhaseType::Opaque, + }; let mut properties = MaterialProperties { mesh_pipeline_key_bits: ErasedMeshPipelineKey::new(MeshPipelineKey::empty()), base_specialize: Some(base_specialize), @@ -641,18 +723,14 @@ impl ErasedRenderAsset for CustomMaterial { 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 { - source_asset.alpha_mode - }, + alpha_mode, + render_phase_type, + depth_bias: source_asset.depth_bias, ..Default::default() }; - properties.add_draw_function(MainPassOpaqueDrawFunction, draw_function); - properties.add_draw_function(MainPassTransparentDrawFunction, draw_function); + properties.add_draw_function(MainPassOpaqueDrawFunction, draw_opaque); + properties.add_draw_function(MainPassAlphaMaskDrawFunction, draw_alpha_mask); + properties.add_draw_function(MainPassTransparentDrawFunction, draw_transparent); if source_asset.has_vertex { properties.add_shader(MaterialVertexShader, source_asset.shader_handle.clone()); } @@ -665,6 +743,22 @@ impl ErasedRenderAsset for CustomMaterial { properties: Arc::new(properties), }) } + + fn unload_asset( + source_asset: AssetId, + (_, _, _, _, _, bind_group_allocators, render_material_bindings, _, _): &mut SystemParamItem< + Self::Param, + >, + ) { + // Mirror bevy_pbr's Material unload: without this the per-frame + // blend-state clones leak a RenderMaterialBindings entry and a + // bind-group slot every frame. + if let Some(binding) = render_material_bindings.remove(&source_asset.untyped()) + && let Some(allocator) = bind_group_allocators.get_mut(&TypeId::of::()) + { + allocator.free(binding); + } + } } fn extract_custom_materials( diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index f449ab7d..55ff83ef 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -134,6 +134,31 @@ pub fn set_property( return Ok(()); } + // Textures (2D images and 3D voxel textures alike) bind by name into + // the material's dynamic shader; `create_bindings` resolves the + // GpuImage at prepare time. `texture_handle` was resolved above. + if let ShaderValue::Texture(_) = &value { + let handle = texture_handle.expect("texture_handle resolved for ShaderValue::Texture"); + let category = mat + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + if !matches!( + category, + ParameterCategory::Texture + | ParameterCategory::StorageTexture + | ParameterCategory::Sampler + ) { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Texture" + ))); + } + mat.shader.insert(&name, handle); + return Ok(()); + } + return custom::set_property(&mut mat, &name, &value); } @@ -316,9 +341,10 @@ pub fn set_depth_write( mat.extension.depth_write = Some(value); Ok(()) } - MaterialMut::Particles(_) => Err(ProcessingError::InvalidArgument( - "depth-write is not yet configurable on particle materials".to_string(), - )), + MaterialMut::Particles(mat) => { + mat.extension.depth_write = Some(value); + Ok(()) + } MaterialMut::Custom(mat) => { mat.depth_write = Some(value); Ok(()) @@ -327,6 +353,30 @@ pub fn set_depth_write( ) } +pub fn set_depth_bias( + In((entity, value)): In<(Entity, f32)>, + 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.depth_bias = value, + MaterialMut::Particles(mat) => mat.base.depth_bias = value, + MaterialMut::Custom(mat) => mat.depth_bias = value, + } + Ok(()) + }, + ) +} + pub fn set_custom_blend( In((entity, blend)): In<(Entity, BlendState)>, material_handles: Query<&UntypedMaterial>, @@ -346,9 +396,11 @@ pub fn set_custom_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::Particles(mat) => { + mat.extension.blend_state = Some(blend); + mat.base.alpha_mode = AlphaMode::Blend; + Ok(()) + } MaterialMut::Custom(mat) => { mat.blend_state = Some(blend); mat.alpha_mode = AlphaMode::Blend; diff --git a/crates/processing_render/src/particles/emit.rs b/crates/processing_render/src/particles/emit.rs index f31fb5aa..7203b650 100644 --- a/crates/processing_render/src/particles/emit.rs +++ b/crates/processing_render/src/particles/emit.rs @@ -6,11 +6,14 @@ use processing_core::app_mut; use processing_core::error; use crate::geometry; -use crate::particles::grid::{Grid, grid_bind, grid_build}; +use crate::particles::grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; 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}; +use crate::{ + buffer_write_element, compute_create, compute_dispatch, compute_set, + shader_load, +}; const WORKGROUP_SIZE: u32 = 64; @@ -171,6 +174,107 @@ pub fn particles_flock( particles_apply(particles_entity, flock_entity) } +#[derive(Component, Clone, Copy)] +pub struct AutoFlockGrid(pub Grid); + +#[derive(Component, Clone, Copy)] +pub struct NaiveFlockKernel(pub Entity); + +const FLOCK_PARAMS: [&str; 8] = [ + "sep_distance", + "neighbor_distance", + "weight_separation", + "weight_alignment", + "weight_cohesion", + "max_speed", + "max_force", + "min_speed", +]; + +pub fn particles_flock_auto(particles_entity: Entity, flock_entity: Entity) -> error::Result<()> { + let (capacity, cell_size, cap, existing, naive) = app_mut(|app| { + let world = app.world(); + let capacity = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .capacity; + let compute = world.get::(flock_entity); + let cell_size = compute + .and_then(|c| c.shader.get::("neighbor_distance").copied()) + .unwrap_or(2.5) + .max(1e-3); + let cap = compute + .and_then(|c| c.shader.get::("max_neighbors").copied()) + .unwrap_or(64); + let existing = world.get::(flock_entity).map(|g| g.0); + let naive = world.get::(flock_entity).map(|n| n.0); + Ok((capacity, cell_size, cap, existing, naive)) + })?; + + if cap == 0 { + let naive = match naive { + Some(e) => e, + None => { + let e = crate::particles::kernels::particles_kernel_flock_naive()?; + app_mut(|app| { + app.world_mut() + .entity_mut(flock_entity) + .insert(NaiveFlockKernel(e)); + Ok(()) + })?; + e + } + }; + let values: Vec<(usize, f32)> = app_mut(|app| { + let compute = app.world().get::(flock_entity); + Ok(FLOCK_PARAMS + .iter() + .enumerate() + .filter_map(|(idx, name)| { + compute + .and_then(|c| c.shader.get::(name).copied()) + .map(|v| (idx, v)) + }) + .collect()) + })?; + for (idx, v) in values { + compute_set(naive, FLOCK_PARAMS[idx], ShaderValue::Float(v))?; + } + return particles_apply(particles_entity, naive); + } + + let reusable = existing.filter(|g| { + g.capacity == capacity && (g.params.cell_size - cell_size).abs() < f32::EPSILON + }); + let grid = match reusable { + Some(g) => g, + None => { + if let Some(old) = existing { + let _ = crate::buffer_destroy(old.offsets); + let _ = crate::buffer_destroy(old.cursor); + let _ = crate::buffer_destroy(old.sorted); + } + const HALF: f32 = 40.0; + let dim = (((2.0 * HALF) / cell_size).ceil() as u32).clamp(1, 32); + let half_extent = dim as f32 * cell_size / 2.0; + let params = GridParams { + min: [-half_extent; 3], + cell_size, + dims: [dim; 3], + }; + let g = grid_create(params, capacity)?; + app_mut(|app| { + app.world_mut() + .entity_mut(flock_entity) + .insert(AutoFlockGrid(g)); + Ok(()) + })?; + g + } + }; + particles_flock(particles_entity, flock_entity, &grid) +} + static NEIGHBOR_COMPUTE: Mutex> = Mutex::new(None); fn neighbor_compute() -> error::Result { @@ -270,6 +374,11 @@ pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> erro } } + // Full dispatch (with app.update()) on purpose: user-facing applies must + // see resources created earlier in the same frame — the strong "set then + // apply just works" invariant. The quiet variant is reserved for the + // internal grid/scan passes, where no new resources can appear between + // dispatches. 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 index 0a31f069..45e6ae27 100644 --- a/crates/processing_render/src/particles/grid.rs +++ b/crates/processing_render/src/particles/grid.rs @@ -6,7 +6,7 @@ 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}; +use crate::{buffer_create, compute_create, compute_dispatch_quiet, 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"; @@ -89,24 +89,24 @@ pub fn grid_build(grid: &Grid, position: Entity) -> Result<()> { 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_dispatch_quiet(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)?; + compute_dispatch_quiet(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_dispatch_quiet(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)?; + compute_dispatch_quiet(scatter, grid.capacity.div_ceil(PARTICLE_WG), 1, 1)?; Ok(()) } diff --git a/crates/processing_render/src/particles/kernels/flock.wgsl b/crates/processing_render/src/particles/kernels/flock.wgsl index 56040c17..82acde6f 100644 --- a/crates/processing_render/src/particles/kernels/flock.wgsl +++ b/crates/processing_render/src/particles/kernels/flock.wgsl @@ -9,6 +9,7 @@ struct FlockParams { max_speed: f32, max_force: f32, min_speed: f32, + max_neighbors: u32, } struct GridParams { @@ -71,35 +72,61 @@ fn main(@builtin(global_invocation_id) gid: vec3) { var coh_sum = vec3(0.0); var flock_count = 0u; - for (var dz = -1; dz <= 1; dz++) { - let cz = bz + dz; + let cap = fp.max_neighbors; + + var starts: array; + var counts: array; + var total = 0u; + for (var m = 0u; m < 27u; m++) { + counts[m] = 0u; + let cx = bx + i32(m % 3u) - 1; + let cy = by + i32((m / 3u) % 3u) - 1; + let cz = bz + i32(m / 9u) - 1; + if cx < 0 || cx >= i32(gp.dims_x) { continue; } + if cy < 0 || cy >= i32(gp.dims_y) { continue; } 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; - } - } + let cell = cell_index(vec3(u32(cx), u32(cy), u32(cz)), dims); + let start = offsets[cell]; + starts[m] = start; + counts[m] = offsets[cell + 1u] - start; + total += counts[m]; + } + + var budget = total; + if cap > 0u { + budget = min(total, cap * 7u); + } + let phase = (i * 2654435761u) % max(total, 1u); + + for (var t = 0u; t < budget; t++) { + var r: u32; + if budget == total { + r = t; + } else { + r = (phase + (t * total) / budget) % total; + } + var m = 0u; + var acc = 0u; + loop { + if r < acc + counts[m] { break; } + acc += counts[m]; + m++; + } + let j = sorted[starts[m] + (r - acc)]; + 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; + if cap > 0u && flock_count >= cap { + break; } } } diff --git a/crates/processing_render/src/particles/kernels/flock_naive.wgsl b/crates/processing_render/src/particles/kernels/flock_naive.wgsl new file mode 100644 index 00000000..af196d1c --- /dev/null +++ b/crates/processing_render/src/particles/kernels/flock_naive.wgsl @@ -0,0 +1,124 @@ +struct Params { + sep_distance: f32, + neighbor_distance: f32, + weight_separation: f32, + weight_alignment: f32, + weight_cohesion: f32, + max_speed: f32, + max_force: f32, + min_speed: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var params: Params; + +const TILE: u32 = 64u; +var s_pos: array, 64>; +var s_vel: array, 64>; + +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); +} + +@compute @workgroup_size(64) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3, +) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + let alive = i < count; + + let sep_d2 = params.sep_distance * params.sep_distance; + let neighbor_d2 = params.neighbor_distance * params.neighbor_distance; + + var pos = vec3(0.0); + var vel = vec3(0.0); + if alive { + let pi = i * 3u; + pos = vec3(position[pi], position[pi + 1u], position[pi + 2u]); + vel = vec3(velocity[pi], velocity[pi + 1u], velocity[pi + 2u]); + } + + 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; + + let num_tiles = (count + TILE - 1u) / TILE; + + for (var t = 0u; t < num_tiles; t++) { + let j = t * TILE + lid.x; + if j < count { + let pj = j * 3u; + s_pos[lid.x] = vec3(position[pj], position[pj + 1u], position[pj + 2u]); + s_vel[lid.x] = vec3(velocity[pj], velocity[pj + 1u], velocity[pj + 2u]); + } + workgroupBarrier(); + + if alive { + let tile_end = min(TILE, count - t * TILE); + for (var k = 0u; k < tile_end; k++) { + let global_j = t * TILE + k; + if global_j == i { continue; } + + let diff = pos - s_pos[k]; + 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 += s_vel[k]; + coh_sum += diff; + flock_count += 1u; + } + } + } + workgroupBarrier(); + } + + if !alive { return; } + + var force = vec3(0.0); + if sep_count > 0u { + force += steer_toward(sep_steer / f32(sep_count), vel, + params.max_speed, params.max_force) * params.weight_separation; + } + if flock_count > 0u { + force += steer_toward(ali_sum / f32(flock_count), vel, + params.max_speed, params.max_force) * params.weight_alignment; + force += steer_toward(-coh_sum / f32(flock_count), vel, + params.max_speed, params.max_force) * params.weight_cohesion; + } + + var new_vel = vel + force; + let speed2 = dot(new_vel, new_vel); + let max_speed_sq = params.max_speed * params.max_speed; + if speed2 > max_speed_sq { + new_vel = new_vel * (params.max_speed * inverseSqrt(speed2)); + } else if params.min_speed > 0.0 { + let min_speed_sq = params.min_speed * params.min_speed; + if speed2 < min_speed_sq && speed2 > 0.0 { + new_vel = new_vel * sqrt(min_speed_sq / speed2); + } + } + + // Steering only: position integration belongs to the INTEGRATE kernel, + // matching the grid flock's convention. + let pi = i * 3u; + velocity[pi] = new_vel.x; + velocity[pi + 1u] = new_vel.y; + velocity[pi + 2u] = new_vel.z; +} diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs index a552af51..c8816f08 100644 --- a/crates/processing_render/src/particles/kernels/mod.rs +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -43,6 +43,7 @@ impl Plugin for ParticlesKernelsPlugin { embedded_asset!(app, "bounds_box.wgsl"); embedded_asset!(app, "impulse.wgsl"); embedded_asset!(app, "flock.wgsl"); + embedded_asset!(app, "flock_naive.wgsl"); embedded_asset!(app, "orient.wgsl"); embedded_asset!(app, "field.wgsl"); embedded_asset!(app, "attr_linear.wgsl"); @@ -63,6 +64,7 @@ impl Plugin for ParticlesKernelsPlugin { embedded_asset!(app, "compact_scatter.wgsl"); embedded_asset!(app, "reduce.wgsl"); embedded_asset!(app, "neighbor.wgsl"); + embedded_asset!(app, "prims_reset.wgsl"); } } @@ -252,6 +254,23 @@ pub fn particles_kernel_impulse() -> error::Result { Ok(entity) } +// Tiled all-pairs exact flock for the `max_neighbors == 0` path in +// `particles_flock_auto`. Velocity-only, like the grid kernel. +pub fn particles_kernel_flock_naive() -> error::Result { + let shader = shader_load("embedded://processing_render/particles/kernels/flock_naive.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.0))?; + 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)?; @@ -264,6 +283,7 @@ pub fn particles_kernel_flock() -> error::Result { 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))?; + compute_set(entity, "max_neighbors", ShaderValue::UInt(64))?; Ok(entity) } diff --git a/crates/processing_render/src/particles/kernels/prims_reset.wgsl b/crates/processing_render/src/particles/kernels/prims_reset.wgsl new file mode 100644 index 00000000..867ab63c --- /dev/null +++ b/crates/processing_render/src/particles/kernels/prims_reset.wgsl @@ -0,0 +1,24 @@ +// Zeroes a primitives target's per-frame counts (drawn vertex count + the +// attempted stat) on the GPU, so the reset is ordered in the command stream +// ahead of the kernels that append — a CPU-side asset write would race the +// dispatches. Layout mirrors PrimsArgs in shaders/processing/prims.wesl; +// instance_count and the capacity tail are left untouched. + +struct PrimsArgs { + vertex_count: atomic, + instance_count: u32, + first_vertex: u32, + first_instance: u32, + attempted: atomic, + capacity_verts: u32, + verts_per_prim: u32, + _pad: u32, +} + +@group(0) @binding(0) var prims_args: PrimsArgs; + +@compute @workgroup_size(1) +fn main() { + atomicStore(&prims_args.vertex_count, 0u); + atomicStore(&prims_args.attempted, 0u); +} diff --git a/crates/processing_render/src/particles/material.rs b/crates/processing_render/src/particles/material.rs index 90b55a98..63a302e3 100644 --- a/crates/processing_render/src/particles/material.rs +++ b/crates/processing_render/src/particles/material.rs @@ -1,5 +1,3 @@ -use std::ops::Deref; - use bevy::asset::embedded_asset; use bevy::material::specialize::SpecializedMeshPipelineError; use bevy::pbr::{ @@ -9,13 +7,11 @@ use bevy::pbr::{ use bevy::prelude::*; use bevy::render::{ mesh::MeshVertexBufferLayoutRef, - render_resource::{AsBindGroup, RenderPipelineDescriptor}, + render_resource::{AsBindGroup, BlendState, RenderPipelineDescriptor}, storage::ShaderBuffer, }; use bevy::shader::ShaderRef; -use crate::render::material::UntypedMaterial; - pub struct ParticlesMaterialPlugin; impl Plugin for ParticlesMaterialPlugin { @@ -31,6 +27,8 @@ pub type ParticlesMaterial = ExtendedMaterial, + pub depth_write: Option, } impl From<&ParticlesExtension> for ParticlesExtensionKey { @@ -38,6 +36,8 @@ impl From<&ParticlesExtension> for ParticlesExtensionKey { Self { has_albedo: ext.colors.is_some(), has_emissive: ext.emissive_colors.is_some(), + blend_state: ext.blend_state, + depth_write: ext.depth_write, } } } @@ -49,6 +49,10 @@ pub struct ParticlesExtension { pub colors: Option>, #[storage(101, read_only)] pub emissive_colors: Option>, + /// Custom blend override for the sketch's `blend_mode()`. + #[reflect(ignore)] + pub blend_state: Option, + pub depth_write: Option, } impl MaterialExtension for ParticlesExtension { @@ -74,17 +78,11 @@ impl MaterialExtension for ParticlesExtension { fragment.shader_defs.push("HAS_EMISSIVE_COLORS".into()); } } + crate::material::apply_pipeline_state( + descriptor, + key.bind_group_data.blend_state, + key.bind_group_data.depth_write, + ); 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 index 38034d73..dabf6fd4 100644 --- a/crates/processing_render/src/particles/mod.rs +++ b/crates/processing_render/src/particles/mod.rs @@ -8,6 +8,7 @@ pub mod kernels; pub mod material; pub mod pack; pub mod point_render; +pub mod prims; pub mod reduce; pub mod scan; mod scatter; @@ -21,9 +22,14 @@ pub use algebra::{ }; pub use compact::compact; pub use emit::{ - particles_apply, particles_emit, particles_emit_gpu, particles_flock, particles_gather, + AutoFlockGrid, particles_apply, particles_emit, particles_emit_gpu, particles_flock, + particles_flock_auto, particles_gather, }; pub use grid::{Grid, GridParams, grid_bind, grid_build, grid_create}; +pub use prims::{ + PrimitivesTarget, particles_primitives_apply, particles_primitives_attempted, + particles_primitives_create, particles_primitives_destroy, particles_primitives_field, +}; 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, @@ -96,11 +102,28 @@ pub struct Particles { pub connectivity: Option, /// Ring-buffer write cursor; wraps at `capacity`. pub emit_head: u32, + /// Set by the raster draw path, consumed by the primitives-apply path so + /// a target's counts reset once per draw regardless of frame pacing. + pub drawn_since_apply: bool, + /// When set, instanced draws of this system are skinned: the pack kernel + /// samples the pose pool by the per-particle phase attribute. + pub skin: Option, +} + +/// Pose-pool attachment for skinned particle instances (see +/// `crate::animation::SkinPosePool`). +#[derive(Clone, Copy)] +pub struct ParticlesSkin { + pub pool: Entity, + pub phase_attr: Entity, } #[derive(Clone, Copy)] pub struct Connectivity { - pub index_buffer: Entity, + /// Index buffer, when the topology shares vertices (mesh-carry or a + /// GPU-generated index buffer). `None` for dynamic-topology targets, + /// whose vertices are unique by construction and draw non-indexed. + pub index_buffer: Option, pub indirect_buffer: Entity, } @@ -110,6 +133,24 @@ impl Particles { } } +/// Attach a pose pool + phase attribute; pack dispatch switches to the +/// skin-enabled pipeline for this system's instanced draws. +pub fn set_skin_pool( + In((particles_entity, pool, phase_attr)): In<(Entity, Entity, Entity)>, + mut particles: Query<&mut Particles>, +) -> Result<()> { + let mut p = particles + .get_mut(particles_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + if !p.buffers.contains_key(&phase_attr) { + return Err(ProcessingError::InvalidArgument( + "skin pool phase attribute is not an attribute of this particle system".to_string(), + )); + } + p.skin = Some(ParticlesSkin { pool, phase_attr }); + Ok(()) +} + #[derive(Component, Clone, Copy)] pub struct ParticlesDraw { pub particles: Entity, @@ -150,6 +191,8 @@ pub fn create( raster_draw_entity: None, connectivity: None, emit_head: 0, + drawn_since_apply: false, + skin: None, }) .id(); Ok(entity) @@ -218,7 +261,7 @@ pub fn create_from_geometry( BufferUsages::INDIRECT, ); Connectivity { - index_buffer, + index_buffer: Some(index_buffer), indirect_buffer, } }); @@ -231,6 +274,8 @@ pub fn create_from_geometry( raster_draw_entity: None, connectivity, emit_head: 0, + drawn_since_apply: false, + skin: None, }) .id(); Ok(entity) @@ -254,12 +299,14 @@ pub fn particles_set_connectivity( .get_mut::(particles_entity) .ok_or(error::ProcessingError::ParticlesNotFound)?; Ok(field.connectivity.replace(Connectivity { - index_buffer, + index_buffer: Some(index_buffer), indirect_buffer, })) })?; if let Some(previous) = previous { - crate::buffer_destroy(previous.index_buffer)?; + if let Some(previous_index) = previous.index_buffer { + crate::buffer_destroy(previous_index)?; + } crate::buffer_destroy(previous.indirect_buffer)?; } Ok(index_buffer) @@ -384,7 +431,9 @@ pub fn destroy( commands.entity(raster_draw_entity).despawn(); } if let Some(connectivity) = p.connectivity { - commands.entity(connectivity.index_buffer).despawn(); + if let Some(index_buffer) = connectivity.index_buffer { + commands.entity(index_buffer).despawn(); + } commands.entity(connectivity.indirect_buffer).despawn(); } commands.entity(entity).despawn(); diff --git a/crates/processing_render/src/particles/pack.rs b/crates/processing_render/src/particles/pack.rs index 0d844113..16b30775 100644 --- a/crates/processing_render/src/particles/pack.rs +++ b/crates/processing_render/src/particles/pack.rs @@ -8,7 +8,7 @@ use bevy::pbr::{ use bevy::platform::collections::HashMap; use bevy::prelude::*; use bevy::render::{ - Extract, ExtractSchedule, Render, RenderApp, RenderSystems, + Extract, ExtractSchedule, Render, RenderApp, RenderStartup, RenderSystems, batching::gpu_preprocessing::BatchedInstanceBuffers, render_asset::RenderAssets, render_resource::{ @@ -22,6 +22,10 @@ use bevy::render::{ }; use bevy::shader::{Shader, ShaderDefVal}; +use bevy::pbr::SkinUniforms; +use bevy::render::render_resource::{BufferInitDescriptor, BufferUsages}; + +use crate::animation::SkinPosePool; use crate::compute; use crate::geometry::BuiltinAttributes; @@ -50,6 +54,7 @@ impl Plugin for ParticlesPackPlugin { .init_resource::() .init_resource::() .init_resource::() + .add_systems(RenderStartup, prewarm_pack_pipelines) .add_systems(ExtractSchedule, extract_particles_draws) .add_systems( Render, @@ -67,6 +72,7 @@ pub struct PackPipelineKey { pub has_rotation: bool, pub has_scale: bool, pub has_life: bool, + pub has_skin: bool, } pub struct CachedPackPipeline { @@ -83,7 +89,7 @@ pub struct ParticlesPackPipelines { struct ParticlesPackParams { base_input_index: u32, count: u32, - _pad0: u32, + pool_size: u32, _pad1: u32, } @@ -93,6 +99,9 @@ pub struct ExtractedParticlesData { pub rotation: Option>, pub scale: Option>, pub life: Option>, + /// Phase attribute buffer + the pool's slot entities (main world), whose + /// live skin indices prepare resolves each frame. + pub skin: Option<(Handle, Vec)>, } #[derive(Resource, Default)] @@ -143,6 +152,10 @@ fn pack_layout_entries(key: PackPipelineKey) -> Vec { entries.push(layout_entry(5, storage_r)); } entries.push(layout_entry(6, uniform)); + if key.has_skin { + entries.push(layout_entry(7, storage_r)); + entries.push(layout_entry(8, storage_r)); + } entries } @@ -166,9 +179,42 @@ fn shader_defs_for(key: PackPipelineKey) -> Vec { if key.has_life { defs.push("HAS_LIFE".into()); } + if key.has_skin { + defs.push("HAS_SKIN".into()); + } defs } +/// Queue every pack-kernel variant up front. +/// +/// The pack kernel is what writes per-instance transforms into the batch slab, +/// so until its pipeline exists an instanced draw renders NOTHING — measured: +/// a particle system drawn from frame 1 is blank on frame 1 and correct on +/// frame 2, while a second system whose first draw happens later appears +/// immediately (the pipeline is hot by then). That one-time cost also lands +/// MID-SHOW the first time a new variant appears — the skinned (`has_skin`) +/// pack for the crow cut is a different pipeline from the unskinned one, so it +/// would otherwise blank its own first frame at the cut. +/// +/// There are only 2^4 = 16 combinations and they are small compute pipelines, +/// so compiling them all at startup is cheap and makes instanced draws behave +/// like Processing expects: draw it, and it draws THIS frame. +fn prewarm_pack_pipelines( + shader: Res, + pipeline_cache: Res, + mut pipelines: ResMut, +) { + for bits in 0..16u8 { + let key = PackPipelineKey { + has_rotation: bits & 0b0001 != 0, + has_scale: bits & 0b0010 != 0, + has_life: bits & 0b0100 != 0, + has_skin: bits & 0b1000 != 0, + }; + get_or_create_pipeline(&mut pipelines, &pipeline_cache, &shader.0, key); + } +} + fn get_or_create_pipeline( pipelines: &mut ParticlesPackPipelines, pipeline_cache: &PipelineCache, @@ -180,16 +226,16 @@ fn get_or_create_pipeline( } let bind_group_layout = BindGroupLayoutDescriptor::new( format!( - "ParticlesPackBindGroupLayout(rot={},scale={},life={})", - key.has_rotation, key.has_scale, key.has_life + "ParticlesPackBindGroupLayout(rot={},scale={},life={},skin={})", + key.has_rotation, key.has_scale, key.has_life, key.has_skin ), &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 + "particles_pack_pipeline(rot={},scale={},life={},skin={})", + key.has_rotation, key.has_scale, key.has_life, key.has_skin ) .into(), ), @@ -213,6 +259,7 @@ fn extract_particles_draws( particles_draws: Extract>, particles_q: Extract>, buffers: Extract>, + pools: Extract>, builtins: Extract>, mut extracted: ResMut, ) { @@ -240,10 +287,24 @@ fn extract_particles_draws( .and_then(|e| buffers.get(e).ok()) .map(|b| b.handle.clone()); + let skin = p.skin.and_then(|ps| { + let phase_entity = p.buffer(ps.phase_attr)?; + let phase_buf = buffers.get(phase_entity).ok()?; + let pool = pools.get(ps.pool).ok()?; + Some(( + phase_buf.handle.clone(), + pool.slots + .iter() + .map(|&e| MainEntity::from(e)) + .collect::>(), + )) + }); + let key = PackPipelineKey { has_rotation: rotation.is_some(), has_scale: scale.is_some(), has_life: life.is_some(), + has_skin: skin.is_some(), }; extracted.by_main.insert( MainEntity::from(entity), @@ -253,6 +314,7 @@ fn extract_particles_draws( rotation, scale, life, + skin, }, ); } @@ -267,6 +329,7 @@ fn prepare_pack_bind_groups( batched_instance_buffers: Res>, culling_data_buffer: Res, gpu_buffers: Res>, + skin_uniforms: Res, render_device: Res, render_queue: Res, mut bind_groups: ResMut, @@ -304,6 +367,34 @@ fn prepare_pack_bind_groups( continue; } + // Resolve the pose pool's live skin indices. Offsets can change any + // frame as skins come and go, so the table is rebuilt per frame; if + // the pool isn't fully extracted yet (first frame or two), skip the + // batch rather than draw with garbage joints. + let mut skin_bindings = None; + if let Some((phase_handle, slots)) = &data.skin { + let Some(gpu_phase) = gpu_buffers.get(phase_handle) else { + continue; + }; + let mut table: Vec = Vec::with_capacity(slots.len()); + for slot in slots { + match skin_uniforms.skin_byte_offset(*slot) { + Some(offset) => table.push(offset.index()), + None => break, + } + } + if table.len() != slots.len() || table.is_empty() { + continue; + } + let bytes: Vec = table.iter().flat_map(|v| v.to_le_bytes()).collect(); + let table_buffer = render_device.create_buffer_with_data(&BufferInitDescriptor { + label: Some("particles_skin_index_table"), + contents: &bytes, + usage: BufferUsages::STORAGE, + }); + skin_bindings = Some((gpu_phase, table_buffer, table.len() as u32)); + } + let pipeline_id = get_or_create_pipeline(&mut pipelines, &pipeline_cache, &shader.0, data.key); if !matches!( @@ -317,6 +408,7 @@ fn prepare_pack_bind_groups( let params = ParticlesPackParams { base_input_index: reservation.input_buffer_base, count: reservation.max_capacity, + pool_size: skin_bindings.as_ref().map(|(_, _, k)| *k).unwrap_or(0), ..default() }; let mut uniform = UniformBuffer::from(params); @@ -358,6 +450,16 @@ fn prepare_pack_bind_groups( binding: 6, resource: uniform.binding().unwrap(), }); + if let Some((gpu_phase, table_buffer, _)) = &skin_bindings { + entries.push(BindGroupEntry { + binding: 7, + resource: gpu_phase.buffer.as_entire_binding(), + }); + entries.push(BindGroupEntry { + binding: 8, + resource: table_buffer.as_entire_binding(), + }); + } let bind_group = render_device.create_bind_group( Some("particles_pack_bind_group"), diff --git a/crates/processing_render/src/particles/pack.wgsl b/crates/processing_render/src/particles/pack.wgsl index c1b0e460..a6d321e3 100644 --- a/crates/processing_render/src/particles/pack.wgsl +++ b/crates/processing_render/src/particles/pack.wgsl @@ -11,6 +11,7 @@ struct MeshInput { timestamp: u32, tag: u32, morph_descriptor_index: u32, + metadata_index: u32, } struct MeshCullingData { @@ -23,7 +24,7 @@ struct MeshCullingData { struct PackParams { base_input_index: u32, count: u32, - _pad0: u32, + pool_size: u32, _pad1: u32, } @@ -40,6 +41,13 @@ struct PackParams { @group(0) @binding(5) var life: array; #endif @group(0) @binding(6) var params: PackParams; +#ifdef HAS_SKIN +// Per-particle animation phase in [0,1) and the pose pool's live skin +// indices (joint-matrix offsets into the global skin array, refreshed every +// frame by prepare — they are not stable across frames). +@group(0) @binding(7) var skin_phase: array; +@group(0) @binding(8) var skin_index_table: array; +#endif fn quat_to_basis(q: vec4) -> mat3x3 { let x = q.x; let y = q.y; let z = q.z; let w = q.w; @@ -103,6 +111,15 @@ fn pack(@builtin(global_invocation_id) gid: vec3) { ); mesh_input_buffer[slot].tag = i; +#ifdef HAS_SKIN + let pool_size = params.pool_size; + let pose = min(u32(fract(skin_phase[i]) * f32(pool_size)), pool_size - 1u); + mesh_input_buffer[slot].current_skin_index = skin_index_table[pose]; + // MESH_FLAGS_SKIN_INSTANCE_COMPOSE_BIT: the vertex shader composes this + // instance's transform with the pooled skeleton pose. + mesh_input_buffer[slot].flags = mesh_input_buffer[slot].flags | (1u << 26u); +#endif + 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 diff --git a/crates/processing_render/src/particles/particles.wgsl b/crates/processing_render/src/particles/particles.wgsl index 05a7419d..990f05c0 100644 --- a/crates/processing_render/src/particles/particles.wgsl +++ b/crates/processing_render/src/particles/particles.wgsl @@ -13,6 +13,7 @@ #import bevy_pbr::{ forward_io::{VertexOutput, FragmentOutput}, pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, + pbr_types::STANDARD_MATERIAL_FLAGS_UNLIT_BIT, } #endif @@ -46,13 +47,21 @@ fn fragment( ); #endif - pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + pbr_input.material.base_color = alpha_discard( + pbr_input.material.flags, + pbr_input.material.alpha_cutoff, + 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); + if (pbr_input.material.flags & STANDARD_MATERIAL_FLAGS_UNLIT_BIT) == 0u { + out.color = apply_pbr_lighting(pbr_input); + } else { + out.color = pbr_input.material.base_color; + } out.color = main_pass_post_lighting_processing(pbr_input, out.color); #endif diff --git a/crates/processing_render/src/particles/point.wgsl b/crates/processing_render/src/particles/point.wgsl index 14d418ed..5e8a8392 100644 --- a/crates/processing_render/src/particles/point.wgsl +++ b/crates/processing_render/src/particles/point.wgsl @@ -40,16 +40,6 @@ fn vertex(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { @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); + // Per-vertex colour is authoritative — see the note in point_render.rs. + return frag.color; } diff --git a/crates/processing_render/src/particles/point_render.rs b/crates/processing_render/src/particles/point_render.rs index 2e8db841..a68cceb4 100644 --- a/crates/processing_render/src/particles/point_render.rs +++ b/crates/processing_render/src/particles/point_render.rs @@ -71,6 +71,7 @@ impl Plugin for ParticlesPointRenderPlugin { } #[derive(Component, Clone, ExtractComponent)] +#[extract_app(RenderApp)] #[require(VisibilityClass)] #[component(on_add = visibility::add_visibility_class::)] pub struct ParticleRasterDraw { @@ -122,9 +123,21 @@ impl Specializer for RasterSpecializer { if key.has_normal { defs.push("HAS_NORMALS"); } - if matches!(topology, Topology::TriangleList | Topology::TriangleStrip) { - defs.push("SHADED"); - } + // NO implicit shading. Triangle prims used to get a hardcoded + // directional light baked into point.wgsl (`SHADED`), with the normal + // derived from screen-space derivatives when the target had none. That + // meant: + // * per-vertex colours were never what you got — a flat sheet facing + // +Z came out at 0.48x, so a shader computing paper 244/240/232 + // could not match `background(244, 240, 232)`; + // * no material call could turn it off, because this path bypasses + // the material system entirely; + // * geometry with more than one orientation shaded in bands. A + // canvas folded into a wall and a floor rendered as two different + // colours across the crease from identical vertex colours (0.48x + // vs 0.89x for the hardcoded light at (0.4, 0.85, 0.35)). + // Prims carry per-vertex colour and the kernel that wrote them owns + // the look; shading is that kernel's business, not the renderer's. for def in defs { descriptor.vertex.shader_defs.push(def.into()); if let Some(fragment) = descriptor.fragment.as_mut() { @@ -260,20 +273,19 @@ fn prepare_raster_bind_groups( 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 indirect = match &draw.indirect { + Some(indirect_handle) => match gpu_buffers.get(indirect_handle) { + Some(indirect_gpu) => Some(indirect_gpu.buffer.clone()), + None => continue, + }, + None => None, + }; + let index = match (&draw.index, indirect.is_some()) { + (Some(index_handle), true) => match gpu_buffers.get(index_handle) { + Some(index_gpu) => Some(index_gpu.buffer.clone()), + None => continue, + }, + _ => None, }; let color_buffer = match &draw.color { Some(handle) => match gpu_buffers.get(handle) { @@ -475,6 +487,8 @@ impl RenderCommand

for DrawParticleRaster { pass.set_index_buffer(index.slice(..), IndexFormat::Uint32); pass.draw_indexed_indirect(indirect, 0); } + // Dynamic-topology targets: unique vertices, GPU-written count. + (None, Some(indirect)) => pass.draw_indirect(indirect, 0), _ => pass.draw(0..entry.count, 0..1), } RenderCommandResult::Success diff --git a/crates/processing_render/src/particles/prims.rs b/crates/processing_render/src/particles/prims.rs new file mode 100644 index 00000000..5e0e9ba9 --- /dev/null +++ b/crates/processing_render/src/particles/prims.rs @@ -0,0 +1,250 @@ +//! Dynamic topology: primitives added by kernels via `processing::prims`. +//! +//! A primitives target owns an internal particle field (the expanded +//! vertices — unique by construction, so no index buffer) plus a widened +//! indirect-args buffer whose head is the GPU-read `DrawIndirect` arguments +//! and whose tail carries stats the sketch can poll. `apply` binds the +//! target's buffers under the `processing::prims` reserved names, resets the +//! counts on the first apply of each frame, and dispatches the kernel over +//! the source field. Drawing reuses the raster connectivity seam with +//! `index_buffer: None` (non-indexed `draw_indirect`). + +use bevy::prelude::*; +use bevy::render::render_resource::BufferUsages; +use processing_core::{app_mut, error}; + +use crate::geometry::Topology; +use crate::particles::{Connectivity, Particles}; +use crate::shader_value::ShaderValue; + +/// Layout of the args buffer, in u32 words. The first four words are the +/// non-indexed `DrawIndirect` arguments; the tail is stats + configuration +/// for `processing::prims` (see `shaders/processing/prims.wesl`). +const ARGS_WORDS: usize = 8; +const ARGS_ATTEMPTED_OFFSET: u64 = 16; + +/// The reserved binding names declared by `processing::prims`. The WESL +/// compiler mangles imported module bindings, so these are matched by +/// suffix against the kernel's reflected parameter names. +const RESERVED_POSITION: &str = "prims_out_position"; +const RESERVED_COLOR: &str = "prims_out_color"; +const RESERVED_ARGS: &str = "prims_args"; + +#[derive(Component)] +pub struct PrimitivesTarget { + /// The internal particle field holding the expanded vertices. + pub field: Entity, + /// The source field the kernels dispatch over. + pub source: Entity, + pub topology: Topology, + pub capacity_prims: u32, + pub verts_per_prim: u32, + /// The widened indirect-args buffer (also the field's connectivity). + pub args: Entity, + /// One-thread kernel that zeroes the per-frame counts in `args`. Reset + /// must happen on the GPU: a CPU-side asset write races the kernel + /// dispatches, silently clobbering a frame's output either way it lands. + pub reset_kernel: Entity, + /// Shader entity used only by `reset_kernel`; retained so destruction can + /// release the complete internally-created target graph. + pub reset_shader: Entity, +} + +fn verts_per_prim(topology: Topology) -> error::Result { + match topology { + Topology::PointList => Ok(1), + Topology::LineList => Ok(2), + Topology::TriangleList => Ok(3), + _ => Err(error::ProcessingError::InvalidArgument(format!( + "primitives targets support POINTS, LINES, and TRIANGLES; \ + strips have shared vertices — use an index buffer instead \ + (got {topology:?})", + ))), + } +} + +fn u32s_to_bytes(values: &[u32]) -> Vec { + values.iter().flat_map(|v| v.to_le_bytes()).collect() +} + +/// Creates a primitives target over `source`. Backs `p.primitives(...)`. +pub fn particles_primitives_create( + source: Entity, + topology: Topology, + capacity_prims: u32, +) -> error::Result { + let vpp = verts_per_prim(topology)?; + let capacity_verts = capacity_prims + .checked_mul(vpp) + .ok_or_else(|| error::ProcessingError::InvalidArgument("capacity overflow".to_string()))?; + + let position = crate::geometry_attribute_position(); + let color = crate::geometry_attribute_color(); + let field = crate::particles_create(capacity_verts, vec![position, color])?; + + let args = crate::buffer_create_with_usage( + ARGS_WORDS as u64 * 4, + BufferUsages::INDIRECT | BufferUsages::STORAGE, + )?; + crate::buffer_write( + args, + u32s_to_bytes(&[0, 1, 0, 0, 0, capacity_verts, vpp, 0]), + )?; + + let reset_shader = crate::shader_load( + "embedded://processing_render/particles/kernels/prims_reset.wgsl", + )?; + let reset_kernel = crate::compute_create(reset_shader)?; + crate::compute_set(reset_kernel, "prims_args", ShaderValue::Buffer(args))?; + + app_mut(|app| { + let mut field_data = app + .world_mut() + .get_mut::(field) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field_data.connectivity = Some(Connectivity { + index_buffer: None, + indirect_buffer: args, + }); + Ok(app + .world_mut() + .spawn(PrimitivesTarget { + field, + source, + topology, + capacity_prims, + verts_per_prim: vpp, + args, + reset_kernel, + reset_shader, + }) + .id()) + }) +} + +/// Destroy a dynamic-topology target and every entity it created internally. +/// The source particle field is borrowed and deliberately survives. +pub fn particles_primitives_destroy(target_entity: Entity) -> error::Result<()> { + let (field, reset_kernel, reset_shader) = app_mut(|app| { + let target = app + .world() + .get::(target_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + Ok((target.field, target.reset_kernel, target.reset_shader)) + })?; + + // Destroy the internal field first: it owns the widened indirect args + // buffer through Connectivity. The reset compute borrows that buffer. + crate::particles_destroy(field)?; + crate::compute_destroy(reset_kernel)?; + crate::shader_destroy(reset_shader)?; + app_mut(|app| { + app.world_mut().entity_mut(target_entity).despawn(); + Ok(()) + }) +} + +/// Resolves a reserved `processing::prims` binding to its mangled reflected +/// name in the kernel, then binds the buffer through the normal path. +fn set_reserved_buffer( + compute_entity: Entity, + reserved: &str, + buffer_entity: Entity, +) -> error::Result<()> { + let actual = app_mut(|app| { + let compute = app + .world() + .get::(compute_entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + let reflection = compute.shader.reflection(); + let name = reflection + .parameters() + .filter_map(|p| p.name().map(String::from)) + .find(|n| n == reserved || n.ends_with(reserved) || n.contains(reserved)); + Ok(name) + })?; + let Some(actual) = actual else { + return Err(error::ProcessingError::InvalidArgument(format!( + "kernel does not import `processing::prims` (no `{reserved}` binding); \ + add `import {{ processing::prims }};` to add primitives from a kernel", + ))); + }; + crate::compute_set(compute_entity, actual, ShaderValue::Buffer(buffer_entity)) +} + +/// Applies `kernel` over the target's source field with the target's buffers +/// bound under the `processing::prims` reserved names. The first apply after +/// the target was drawn resets the primitive count; further applies before +/// the next draw append. +pub fn particles_primitives_apply( + target_entity: Entity, + compute_entity: Entity, +) -> error::Result<()> { + let (field, source, args, reset_kernel, drawn) = app_mut(|app| { + let target = app + .world() + .get::(target_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let (field, source, args, reset_kernel) = + (target.field, target.source, target.args, target.reset_kernel); + let mut field_data = app + .world_mut() + .get_mut::(field) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let drawn = std::mem::take(&mut field_data.drawn_since_apply); + Ok((field, source, args, reset_kernel, drawn)) + })?; + + if drawn { + crate::compute_dispatch(reset_kernel, 1, 1, 1)?; + } + + let (position_buf, color_buf) = app_mut(|app| { + let world = app.world(); + let attrs = world.resource::(); + let (position_attr, color_attr) = (attrs.position, attrs.color); + let field_data = world + .get::(field) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let position = field_data + .buffer(position_attr) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color = field_data + .buffer(color_attr) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + Ok((position, color)) + })?; + + set_reserved_buffer(compute_entity, RESERVED_POSITION, position_buf)?; + set_reserved_buffer(compute_entity, RESERVED_COLOR, color_buf)?; + set_reserved_buffer(compute_entity, RESERVED_ARGS, args)?; + + crate::particles::particles_apply(source, compute_entity) +} + +/// The internal particle field holding a target's expanded vertices (what +/// `particles(target)` draws). +pub fn particles_primitives_field(target_entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(target_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .field) + }) +} + +/// Primitives the kernels attempted to add this frame (including any dropped +/// for capacity). Reads a 4-byte stat from the GPU; call it when you want +/// the number, not every frame. +pub fn particles_primitives_attempted(target_entity: Entity) -> error::Result { + let args = app_mut(|app| { + Ok(app + .world() + .get::(target_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .args) + })?; + let bytes = crate::buffer_read_element(args, ARGS_ATTEMPTED_OFFSET, 4)?; + Ok(u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])) +} diff --git a/crates/processing_render/src/particles/scan.rs b/crates/processing_render/src/particles/scan.rs index bab519e7..6e36e376 100644 --- a/crates/processing_render/src/particles/scan.rs +++ b/crates/processing_render/src/particles/scan.rs @@ -6,7 +6,7 @@ use processing_core::error::Result; use crate::shader_value::ShaderValue; use crate::{ - buffer_create, buffer_destroy, buffer_size, compute_create, compute_dispatch, compute_set, + buffer_create, buffer_destroy, buffer_size, compute_create, compute_dispatch_quiet, compute_set, shader_load, }; @@ -67,7 +67,7 @@ pub fn prefix_sum_u32(buffer: Entity) -> Result<()> { 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)?; + compute_dispatch_quiet(block, num_blocks as u32, 1, 1)?; level_bufs.push(sums); level_ns.push(num_blocks); @@ -82,7 +82,7 @@ pub fn prefix_sum_u32(buffer: Entity) -> Result<()> { 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)?; + compute_dispatch_quiet(add, num_blocks as u32, 1, 1)?; } Ok(()) diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index d514982b..7efecf56 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -1,3 +1,4 @@ +use bevy::math::Affine2; use bevy::math::Affine3A; use bevy::prelude::*; use bevy::render::render_resource::{BlendComponent, BlendFactor, BlendOperation, BlendState}; @@ -515,6 +516,9 @@ pub enum DrawCommand { Roughness(f32), Metallic(f32), Emissive(Color), + Texture(Entity), + NoTexture, + TextureTransform(Affine2), Unlit, Tint(Color), NoTint, diff --git a/crates/processing_render/src/render/filter.rs b/crates/processing_render/src/render/filter.rs index 52a2a9b4..65744cc8 100644 --- a/crates/processing_render/src/render/filter.rs +++ b/crates/processing_render/src/render/filter.rs @@ -158,6 +158,7 @@ pub fn apply(app: &mut App, graphics: Entity, filter: Entity) -> Result<()> { ) }; + if std::env::var("PROCESSING_TRACE_VT").is_ok() { eprintln!("ENGINE filter apply"); } // flush() also extracts the shader asset to the render world. crate::graphics::flush(app, graphics)?; diff --git a/crates/processing_render/src/render/material.rs b/crates/processing_render/src/render/material.rs index d6ab6e4f..5b1a4790 100644 --- a/crates/processing_render/src/render/material.rs +++ b/crates/processing_render/src/render/material.rs @@ -6,6 +6,7 @@ use std::ops::Deref; use crate::material::ProcessingMaterial; use crate::material::custom::{CustomMaterial, CustomMaterial3d}; +use crate::particles::material::ParticlesMaterial; #[derive(Component, Deref)] pub struct UntypedMaterial(pub UntypedHandle); @@ -105,7 +106,7 @@ impl MaterialKey { } } - fn to_standard_material(&self) -> StandardMaterial { + pub(crate) fn to_standard_material(&self) -> StandardMaterial { match self { MaterialKey::Color { transparent, @@ -178,20 +179,37 @@ impl MaterialKey { } } -pub fn add_processing_materials(mut commands: Commands, meshes: Query<(Entity, &UntypedMaterial)>) { +/// Project `UntypedMaterial` onto the typed material component bevy renders +/// with, and clear the slots the handle's type does *not* call for. +/// +/// Draw entities outlive a single draw — every instanced body draw of one +/// particle system shares `particles_data.draw_entity`, and mesh draws reuse +/// their entity across frames — so a typed component left behind by an earlier +/// material of a different type would keep the entity queued in that material's +/// pipeline and overdraw the current one. Inserting without removing is what +/// made a particle system drawn first with a constant-albedo (extended) +/// material and then with a per-particle (particles) material render with the +/// stale extended material. +pub fn sync_typed_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)); - } - } -} + let mut entity = commands.entity(entity); -pub fn add_custom_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(CustomMaterial3d(handle)); + if let Ok(handle) = handle.clone().try_typed::() { + entity + .insert(MeshMaterial3d(handle)) + .remove::() + .remove::>(); + } else if let Ok(handle) = handle.clone().try_typed::() { + entity + .insert(CustomMaterial3d(handle)) + .remove::>() + .remove::>(); + } else if let Ok(handle) = handle.try_typed::() { + entity + .insert(MeshMaterial3d(handle)) + .remove::>() + .remove::(); } } } diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 685f6f80..c7155689 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -12,6 +12,7 @@ use bevy::{ visibility::{NoFrustumCulling, RenderLayers}, }, ecs::system::SystemParam, + log::warn_once, math::{Affine2, Affine3A, Mat4, Vec3A, Vec4}, pbr::gpu_instance_batch::GpuBatchedMesh3d, prelude::*, @@ -25,7 +26,7 @@ use primitive::{ 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 style::{Fill, Style, StyleStack}; use transform::TransformStack; use crate::{ @@ -40,8 +41,21 @@ use crate::{ text::font::TextContext, }; +/// How far each successive batch in a frame steps TOWARD the camera, so that +/// draws resolve in painter's order: later submissions are nearer, which is +/// what both arbiters of order want. The reverse-z depth test (GreaterEqual) +/// keeps the nearest fragment, and `Transparent3d` sorts back-to-front and +/// paints the nearest last. Stepping away from the camera instead inverts +/// BOTH -- the frame renders in exact reverse submission order. pub(crate) const BATCH_INDEX_STEP: f32 = 0.001; +/// View-space slab the 2d projection opens on the near side, so the +/// `BATCH_INDEX_STEP` staircase has somewhere to climb before it reaches the +/// near plane. At 0.001 units a batch this is room for 64k draws in one +/// frame. It only ever adds visible volume, so nothing a sketch used to draw +/// can fall out of the frustum because of it. +pub(crate) const NEAR_HEADROOM: f32 = 64.0; + #[derive(Component)] #[relationship(relationship_target = TransientMeshes)] pub struct BelongsToGraphics(pub Entity); @@ -222,6 +236,25 @@ pub fn flush_draw_commands( pbr.blend_state = None; state.style.material_key = pbr.into(); } + DrawCommand::Texture(entity) => { + if let Ok(img) = p_images.get(entity) { + let mut pbr = state.style.material_key.as_pbr(); + pbr.base_color_texture = Some(img.handle.clone()); + state.style.material_key = pbr.into(); + } + } + DrawCommand::NoTexture => { + if let MaterialKey::Pbr { .. } = &state.style.material_key { + let mut pbr = state.style.material_key.as_pbr(); + pbr.base_color_texture = None; + state.style.material_key = pbr.into(); + } + } + DrawCommand::TextureTransform(transform) => { + let mut pbr = state.style.material_key.as_pbr(); + pbr.uv_transform = transform; + state.style.material_key = pbr.into(); + } DrawCommand::Unlit => { state.style.material_key = MaterialKey::Color { transparent: state.fill_is_transparent(), @@ -915,12 +948,32 @@ pub fn flush_draw_commands( *blend_state, ) } + // The Color key relies on per-vertex colors, which 2D + // tessellation bakes in but shared model meshes lack — + // the fill has to become the material's base color. + MaterialKey::Color { .. } => { + let mut base = material_key.to_standard_material(); + base.base_color = state + .style + .fill + .color() + .unwrap_or(bevy::color::Color::WHITE); + res.materials + .add(ProcessingExtendedMaterial { + base, + extension: ProcessingMaterial { + blend_state: material_key.blend_state(), + depth_write: None, + }, + }) + .untyped() + } _ => material_key.to_material(&mut res.materials), }; flush_batch(&mut res, &mut batch, &p_material_handles); - let z_offset = -(batch.draw_index as f32 * BATCH_INDEX_STEP); + let z_offset = batch.draw_index as f32 * BATCH_INDEX_STEP; let mut transform = state.transform.to_bevy_transform(); // if the "source" geometry was parented in a gltf scene, we need to make sure that @@ -932,13 +985,16 @@ pub fn flush_draw_commands( } transform.translation.z += z_offset; - res.commands.spawn(( + let mut entity = res.commands.spawn(( Mesh3d(geometry.handle.clone()), UntypedMaterial(material_handle), BelongsToGraphics(batch.graphics_entity), transform, batch.render_layers.clone(), )); + apply_stroke(&mut entity, &state.style); + warn_if_unstrokeable(&res.meshes, &geometry.handle, &state.style); + warn_if_skinned(&res.meshes, &geometry.handle); batch.draw_index += 1; } @@ -965,20 +1021,27 @@ pub fn flush_draw_commands( }; let position = buffer.handle.clone(); let count = particles_data.capacity; + particles_data.drawn_since_apply = true; 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 { + let Ok(indirect_buf) = + 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()), - ) + let index = match connectivity.index_buffer { + Some(index_entity) => { + let Ok(index_buf) = p_particle_buffers.get(index_entity) else { + warn!("connectivity buffers for {:?} not found", particles); + continue; + }; + Some(index_buf.handle.clone()) + } + None => None, + }; + (index, Some(indirect_buf.handle.clone())) } None => (None, None), }; @@ -997,6 +1060,14 @@ pub fn flush_draw_commands( flush_batch(&mut res, &mut batch, &p_material_handles); + // Processing parity: the default BLEND mode (style state + // `None`) alpha-composites; REPLACE is the opaque + // overwrite fast path. + let blend = match state.style.blend_state { + None => Some(BlendState::ALPHA_BLENDING), + Some(bs) if bs == BlendState::REPLACE => None, + other => other, + }; let raster_draw = crate::particles::point_render::ParticleRasterDraw { position, count, @@ -1005,7 +1076,7 @@ pub fn flush_draw_commands( indirect, color, normal, - blend: state.style.blend_state, + blend, }; match particles_data.raster_draw_entity { Some(e) => { @@ -1065,13 +1136,24 @@ pub fn flush_draw_commands( warn!("Could not find material for entity {:?}", mat_entity); continue; }; - clone_custom_material_with_blend( - &mut res.custom_materials, - &untyped.0, - *blend_state, - ) + clone_material_with_blend(&mut res, &untyped.0, *blend_state) + } + _ => { + // map fill to the base color + let mut base = material_key.to_standard_material(); + if let Some(color) = state.style.fill.color() { + base.base_color = color; + } + res.materials + .add(ProcessingExtendedMaterial { + base, + extension: ProcessingMaterial { + blend_state: material_key.blend_state(), + depth_write: None, + }, + }) + .untyped() } - _ => material_key.to_material(&mut res.materials), } }; @@ -1486,6 +1568,39 @@ fn clone_custom_material_with_blend( } } +fn clone_material_with_blend( + res: &mut RenderResources, + original: &UntypedHandle, + blend_state: Option, +) -> UntypedHandle { + let Some(bs) = blend_state else { + return original.clone(); + }; + + if let Ok(handle) = original + .clone() + .try_typed::() + { + if let Some(original_mat) = res.particles_materials.get(&handle) { + let mut variant = original_mat.clone(); + variant.extension.blend_state = Some(bs); + variant.base.alpha_mode = AlphaMode::Blend; + return res.particles_materials.add(variant).untyped(); + } + } + + if let Ok(handle) = original.clone().try_typed::() { + if let Some(original_mat) = res.materials.get(&handle) { + let mut variant = original_mat.clone(); + variant.extension.blend_state = Some(bs); + variant.base.alpha_mode = AlphaMode::Blend; + return res.materials.add(variant).untyped(); + } + } + + clone_custom_material_with_blend(&mut res.custom_materials, original, Some(bs)) +} + fn material_key_with_color( key: &MaterialKey, color: Color, @@ -1534,6 +1649,8 @@ fn particles_fill_material( extension: ParticlesExtension { colors: Some(buf.handle.clone()), emissive_colors: None, + blend_state: None, + depth_write: None, }, }); Some(handle.untyped()) @@ -1595,7 +1712,7 @@ fn flush_batch( material_handles: &Query<&UntypedMaterial>, ) { if let Some(mesh) = batch.current_mesh.take() { - let z_offset = -(batch.draw_index as f32 * BATCH_INDEX_STEP); + let z_offset = batch.draw_index as f32 * BATCH_INDEX_STEP; spawn_mesh(res, batch, mesh, z_offset, material_handles); batch.draw_index += 1; } @@ -1609,8 +1726,6 @@ fn add_shape3d( mesh: Mesh, material_handles: &Query<&UntypedMaterial>, ) { - use bevy::pbr::wireframe::{Wireframe, WireframeColor, WireframeLineWidth, WireframeTopology}; - flush_batch(res, batch, material_handles); let mesh_handle = res.meshes.add(mesh); @@ -1657,7 +1772,7 @@ fn add_shape3d( } }; - let z_offset = -(batch.draw_index as f32 * BATCH_INDEX_STEP); + let z_offset = batch.draw_index as f32 * BATCH_INDEX_STEP; let mut transform = state.transform.to_bevy_transform(); transform.translation.z += z_offset; @@ -1669,22 +1784,102 @@ fn add_shape3d( batch.render_layers.clone(), )); - if let Some(stroke_color) = state.style.stroke_color { - entity.insert(( - Wireframe, - WireframeColor { - color: stroke_color, - }, - WireframeLineWidth { - width: state.style.stroke_weight, - }, - WireframeTopology::Quads, - )); - } + apply_stroke(&mut entity, &state.style); batch.draw_index += 1; } +/// Say so when a mesh has a `stroke()` that cannot be drawn. +/// +/// The wireframe pass pulls vertices through the index buffer, so it silently +/// skips a mesh that has none -- the stroke just does not appear, with no +/// error anywhere. Meshes built in-engine are always indexed; ones that +/// arrive through `Geometry` are whatever the file and the loader between +/// them produced, which for a gltf primitive is often non-indexed. +fn warn_if_unstrokeable(meshes: &Assets, handle: &Handle, style: &Style) { + if style.stroke_color.is_none() { + return; + } + if meshes + .get(handle) + .is_some_and(|mesh| mesh.indices().is_none()) + { + warn_once!( + "stroke() is set on a mesh with no index buffer, so no outline will be drawn. \ + The wireframe pass reads vertices through the indices; a mesh loaded without \ + them (a gltf primitive with no normals, for instance) cannot be outlined." + ); + } +} + +/// Say so when a rigged mesh is drawn immediate-mode and its fill will not +/// appear. +/// +/// bevy decides a mesh is skinned from its vertex layout alone -- joint index +/// and joint weight attributes present -- and specializes the pipeline for +/// skinning on that basis, without reference to the entity or the material. A +/// single `shape()` draw has no skin to hand it, so the fill quietly produces +/// nothing: no error, no pixels, whatever material is set. `stroke()` is +/// unaffected, because the wireframe pass reads positions straight out of the +/// vertex buffer and never asks about skinning -- which is how a rigged mesh +/// can come out as an outline with nothing inside it. +/// +/// The instanced path is the one that can pose a rigged mesh: `particles()` +/// with a skin pool supplies the per-instance skin this draw cannot. +fn warn_if_skinned(meshes: &Assets, handle: &Handle) { + let is_skinned = meshes.get(handle).is_some_and(|mesh| { + mesh.attribute(Mesh::ATTRIBUTE_JOINT_INDEX).is_some() + && mesh.attribute(Mesh::ATTRIBUTE_JOINT_WEIGHT).is_some() + }); + if is_skinned { + warn_once!( + "a rigged (skinned) mesh is being drawn as a single shape, so its fill will \ + not appear: the mesh's joint attributes make the pipeline expect per-instance \ + skin data that a single draw does not supply, whatever material is set. \ + stroke() still outlines it. To draw a rigged mesh posed, put it through the \ + instanced path with a skin pool." + ); + } +} + +/// Attach `stroke()` to a just-spawned 3d mesh, as a wireframe over its edges. +/// +/// Every 3d mesh spawn goes through here, because in Processing `stroke()` +/// applies to whatever you draw rather than to a privileged list of shapes: +/// the built-in primitives `add_shape3d` builds, and the `DrawCommand::Geometry` +/// path that `shape()`, `createShape()` meshes and loaded glTF models take. +/// +/// `WireframeTopology::Quads` suppresses the diagonal shared by a pair of +/// triangles that form a quad, which is what makes `box()` draw its 12 real +/// edges instead of all 18 with face diagonals -- the classic Processing look. +/// Detection is index-order based (consecutive triangle pairs sharing two +/// vertices), so a quad-derived mesh -- cuboid, uv sphere, plane -- gets the +/// quad edges, and a mesh that is genuinely triangles falls through to its +/// triangle edges instead of getting a wrong answer. +/// +/// `WireframeLineWidth` is in screen-space pixels, which is the unit +/// `strokeWeight` already means everywhere else. +fn apply_stroke(entity: &mut EntityCommands, style: &Style) { + use bevy::pbr::wireframe::{Wireframe, WireframeColor, WireframeLineWidth, WireframeTopology}; + + // `no_stroke()` clears the color, and that is the whole switch -- there is + // no separate enable flag to consult. + let Some(stroke_color) = style.stroke_color else { + return; + }; + + entity.insert(( + Wireframe, + WireframeColor { + color: stroke_color, + }, + WireframeLineWidth { + width: style.stroke_weight, + }, + WireframeTopology::Quads, + )); +} + /// 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 { diff --git a/crates/processing_render/src/render/primitive/mod.rs b/crates/processing_render/src/render/primitive/mod.rs index c4f20d63..bc6e66ae 100644 --- a/crates/processing_render/src/render/primitive/mod.rs +++ b/crates/processing_render/src/render/primitive/mod.rs @@ -31,8 +31,8 @@ pub use shape::{ build_polygon_stroke, }; pub use shape3d::{ - box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, grid_mesh, plane_mesh, - sphere_mesh, tetrahedron_mesh, torus_mesh, + box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, ensure_vertex_colors, + 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 1e4889b3..bb5f4583 100644 --- a/crates/processing_render/src/render/primitive/shape3d.rs +++ b/crates/processing_render/src/render/primitive/shape3d.rs @@ -3,7 +3,15 @@ use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use bevy::render::mesh::VertexAttributeValues; -fn ensure_vertex_colors(mesh: &mut Mesh) { +/// Give a mesh the white vertex-color attribute if it has none. +/// +/// Every mesh the engine draws is expected to carry one -- the `VertexLayout` +/// the geometry paths declare names `color` -- so a mesh that arrives without +/// it (from a gltf primitive, say) has to be brought up to the same shape. +/// White is the identity for the shaders that consume it, so a mesh that +/// already has colors keeps them and one that does not is unchanged in +/// appearance. +pub fn ensure_vertex_colors(mesh: &mut Mesh) { if mesh.attribute(Mesh::ATTRIBUTE_COLOR).is_none() { let vertex_count = mesh .attribute(Mesh::ATTRIBUTE_POSITION) diff --git a/crates/processing_render/src/surface.rs b/crates/processing_render/src/surface.rs index 4feb630f..0b098509 100644 --- a/crates/processing_render/src/surface.rs +++ b/crates/processing_render/src/surface.rs @@ -24,11 +24,13 @@ use bevy::{ camera::RenderTarget, ecs::query::QueryEntityError, math::{IRect, IVec2}, - prelude::{Commands, Component, Entity, In, Query, ResMut, Window, With, default}, + prelude::{ + Commands, Component, Entity, In, MessageWriter, Query, ResMut, Window, With, default, + }, render::render_resource::{Extent3d, TextureFormat}, window::{ - CompositeAlphaMode, Monitor, RawHandleWrapper, WindowLevel, WindowMode, WindowPosition, - WindowRef, WindowResolution, WindowWrapper, + CompositeAlphaMode, Monitor, PresentMode, RawHandleWrapper, WindowLevel, WindowMode, + WindowPosition, WindowRef, WindowResized, WindowResolution, WindowWrapper, }, }; use raw_window_handle::{ @@ -418,6 +420,7 @@ pub fn resize( In((window_entity, width, height)): In<(Entity, u32, u32)>, mut windows: Query<&mut Window>, mut graphics_query: Query<(&RenderTarget, &mut SurfaceSize)>, + mut resized: MessageWriter, ) -> Result<()> { let width = width.max(1); let height = height.max(1); @@ -439,9 +442,68 @@ pub fn resize( *surface_size = SurfaceSize(width, height); } } + + // `camera_system` recomputes a camera's `clip_from_view` off this message + // and nothing else -- it does not watch `Window` for changes. Without it a + // resized surface keeps the projection it was built with: the swapchain and + // `SurfaceSize` follow, but 2d drawing coordinates stop meaning content + // pixels and a 3d camera keeps the stale aspect ratio. The Rust-side GLFW + // runner used to write this itself, which left the FFI path (every + // JVM-hosted sketch, and every `window_set_size` on a secondary window) + // without it. Writing it here covers both callers. + resized.write(WindowResized { + window: window_entity, + width: width as f32, + height: height as f32, + }); Ok(()) } +/// Set a window surface's swapchain present mode. +/// +/// `vsync` false selects [`PresentMode::AutoNoVsync`], asking for a draw loop +/// that is not paced to the display's refresh rate (falling back `Immediate` +/// -> `Mailbox` -> `Fifo` where the platform lacks it, unlike bare +/// `Immediate` / `Mailbox`, which panic when unsupported). That is what an +/// offline render wants: nothing is watching the window, and captures read +/// back the canvas texture rather than the swapchain, so tearing cannot reach +/// the file. +/// +/// `vsync` true restores [`PresentMode::AutoVsync`]. The window is created +/// vsynced ([`PresentMode::Fifo`], bevy's `Window` default), so a caller that +/// never touches this keeps the paced behaviour. +/// +/// **This buys nothing on macOS** (measured 2026-08-30, `quil/dev/vsync_probe.clj`). +/// Metal offers only `Fifo` and `Immediate`, so `AutoNoVsync` resolves to +/// `Immediate` = `CAMetalLayer.setDisplaySyncEnabled(false)` — and a composited +/// `CAMetalLayer` still gets its drawables released on the compositor's +/// cadence, so `nextDrawable` stays paced by refresh regardless. A trivial +/// draw measures 120.0 fps vsynced and 118.4 fps not, on a 120 Hz display. +/// What actually sets the ceiling there is the window's SIZE: a 3840x4320 +/// window paces at exactly 60 fps in both modes, because compositing it does +/// not fit in one vblank. Render offscreen behind a small window instead. +pub fn set_vsync( + In((window_entity, vsync)): In<(Entity, bool)>, + mut windows: Query<&mut Window>, +) -> Result<()> { + let mode = if vsync { + PresentMode::AutoVsync + } else { + PresentMode::AutoNoVsync + }; + if let Ok(mut window) = windows.get_mut(window_entity) { + // Guarded so an unchanged mode doesn't trip change detection: bevy + // reconfigures the swapchain whenever `present_mode` differs from the + // extracted one, and there is no reason to churn it every call. + if window.present_mode != mode { + window.present_mode = mode; + } + Ok(()) + } else { + Err(error::ProcessingError::SurfaceNotFound) + } +} + pub fn set_pixel_density( In((window_entity, density)): In<(Entity, f32)>, mut windows: Query<&mut Window>, diff --git a/crates/processing_video/Cargo.toml b/crates/processing_video/Cargo.toml new file mode 100644 index 00000000..fcb3870b --- /dev/null +++ b/crates/processing_video/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "processing_video" +version = "0.0.1" +edition = "2024" + +[lints] +workspace = true + +[dependencies] +bevy = { workspace = true } +nannou_video = { git = "https://github.com/nannou-org/nannou", branch = "bevy-refactor" } +processing_core = { workspace = true } +processing_render = { workspace = true } +thiserror = "2" +video-rs = "0.11" + +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-foundation = "0.3" +objc2-core-foundation = "0.3" +objc2-core-video = { version = "0.3", features = ["objc2-io-surface"] } +objc2-core-media = "0.3" +objc2-av-foundation = { version = "0.3", features = [ + "objc2-core-media", + "objc2-core-video", + "block2", +] } +objc2-io-surface = "0.3" +block2 = "0.6" + +[dev-dependencies] +tempfile = "3" diff --git a/crates/processing_video/examples/record_demo.rs b/crates/processing_video/examples/record_demo.rs new file mode 100644 index 00000000..643e959a --- /dev/null +++ b/crates/processing_video/examples/record_demo.rs @@ -0,0 +1,43 @@ +//! Renders an animated plasma pattern to a video file. +//! +//! cargo run -p processing_video --example record_demo -- out.mp4 + +use processing_video::{VideoRecorder, VideoRecorderConfig}; + +const WIDTH: u32 = 640; +const HEIGHT: u32 = 360; +const FPS: f64 = 30.0; +const SECONDS: f64 = 6.0; + +fn main() { + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "record_demo.mp4".to_string()); + + let config = VideoRecorderConfig::new(WIDTH, HEIGHT, FPS); + let mut recorder = VideoRecorder::new(&path, config).expect("create recorder"); + + let frames = (SECONDS * FPS) as u64; + let mut pixels = vec![0u8; (WIDTH * HEIGHT * 4) as usize]; + for i in 0..frames { + let t = i as f32 / FPS as f32; + for y in 0..HEIGHT { + for x in 0..WIDTH { + let (fx, fy) = (x as f32, y as f32); + let v = (fx / 32.0 + t).sin() + + (fy / 24.0 - t * 1.3).sin() + + ((fx + fy) / 48.0 + t * 0.7).sin() + + ((fx * fx + fy * fy).sqrt() / 24.0 - t * 2.0).sin(); + let p = &mut pixels[((y * WIDTH + x) * 4) as usize..][..4]; + p[0] = ((v * std::f32::consts::PI).sin() * 127.0 + 128.0) as u8; + p[1] = ((v * std::f32::consts::PI + 2.0).sin() * 127.0 + 128.0) as u8; + p[2] = ((v * std::f32::consts::PI + 4.0).sin() * 127.0 + 128.0) as u8; + p[3] = 255; + } + } + recorder.record_frame(pixels.clone()).expect("record frame"); + } + + let encoded = recorder.finish().expect("finish"); + println!("wrote {encoded} frames to {path}"); +} diff --git a/crates/processing_video/src/lib.rs b/crates/processing_video/src/lib.rs new file mode 100644 index 00000000..4c353d90 --- /dev/null +++ b/crates/processing_video/src/lib.rs @@ -0,0 +1,224 @@ +use bevy::asset::AssetPath; +use bevy::asset::io::AssetSourceId; +use bevy::ecs::system::RunSystemOnce; +use bevy::prelude::*; + +pub use nannou_video::{ + HwAccelPolicy, NannouVideoPlugin, NetworkPreset, PlaybackMode, SeekTo, Video, + VideoAssetLoaderError, VideoEnded, VideoFailed, VideoLoaded, VideoLoaderSettings, VideoLooped, + VideoOutput, VideoPlayer, VideoResize, VideoSeeked, VideoSource, +}; + +mod record; +pub use record::{ + RecorderCodec, RecorderPixelFormat, VideoRecordError, VideoRecorder, VideoRecorderConfig, +}; + +/// GPU-resident recording through AVFoundation; macOS only. +#[cfg(target_os = "macos")] +pub mod macos; + +use processing_core::app_mut; +use processing_core::error::{ProcessingError, Result}; +use processing_render::image; + +#[derive(Component)] +pub struct ProcessingVideo; + +fn create(In(handle): In>, mut commands: Commands) -> Entity { + commands + .spawn((ProcessingVideo, VideoPlayer::new(handle))) + .id() +} + +fn create_with_mode( + In((handle, mode)): In<(Handle