diff --git a/Cargo.lock b/Cargo.lock index f4ff552..827bfd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -180,7 +180,6 @@ dependencies = [ "ndk-context", "ndk-sys", "num_enum", - "simd_cesu8", "thiserror 2.0.18", ] @@ -518,7 +517,7 @@ checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" [[package]] name = "bevy" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_internal", ] @@ -526,7 +525,7 @@ dependencies = [ [[package]] name = "bevy_a11y" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_app", @@ -538,7 +537,7 @@ dependencies = [ [[package]] name = "bevy_android" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "android-activity", ] @@ -546,7 +545,7 @@ dependencies = [ [[package]] name = "bevy_animation" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_animation_macros", "bevy_app", @@ -578,7 +577,7 @@ dependencies = [ [[package]] name = "bevy_animation_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -588,7 +587,7 @@ dependencies = [ [[package]] name = "bevy_anti_alias" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -609,7 +608,7 @@ dependencies = [ [[package]] name = "bevy_app" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_derive", "bevy_ecs", @@ -630,7 +629,7 @@ dependencies = [ [[package]] name = "bevy_asset" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-broadcast", "async-channel", @@ -673,7 +672,7 @@ dependencies = [ [[package]] name = "bevy_asset_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -684,7 +683,7 @@ dependencies = [ [[package]] name = "bevy_audio" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -699,7 +698,7 @@ dependencies = [ [[package]] name = "bevy_camera" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -724,7 +723,7 @@ dependencies = [ [[package]] name = "bevy_camera_controller" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_camera", @@ -741,7 +740,7 @@ dependencies = [ [[package]] name = "bevy_clipboard" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -755,7 +754,7 @@ dependencies = [ [[package]] name = "bevy_color" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_math", "bevy_reflect", @@ -770,7 +769,7 @@ dependencies = [ [[package]] name = "bevy_core_pipeline" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -812,7 +811,7 @@ dependencies = [ [[package]] name = "bevy_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -822,7 +821,7 @@ dependencies = [ [[package]] name = "bevy_dev_tools" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -853,7 +852,7 @@ dependencies = [ [[package]] name = "bevy_diagnostic" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "atomic-waker", "bevy_app", @@ -870,7 +869,7 @@ dependencies = [ [[package]] name = "bevy_ecs" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "arrayvec", "bevy_ecs_macros", @@ -897,7 +896,7 @@ dependencies = [ [[package]] name = "bevy_ecs_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -908,7 +907,7 @@ dependencies = [ [[package]] name = "bevy_encase_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "encase_derive_impl", @@ -917,7 +916,7 @@ dependencies = [ [[package]] name = "bevy_feathers" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -948,7 +947,7 @@ dependencies = [ [[package]] name = "bevy_gilrs" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -963,7 +962,7 @@ dependencies = [ [[package]] name = "bevy_gizmos" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -985,7 +984,7 @@ dependencies = [ [[package]] name = "bevy_gizmos_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -995,7 +994,7 @@ dependencies = [ [[package]] name = "bevy_gizmos_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1023,7 +1022,7 @@ dependencies = [ [[package]] name = "bevy_gltf" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-lock", "base64", @@ -1058,7 +1057,7 @@ dependencies = [ [[package]] name = "bevy_image" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1086,7 +1085,7 @@ dependencies = [ [[package]] name = "bevy_input" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1102,7 +1101,7 @@ dependencies = [ [[package]] name = "bevy_input_focus" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1118,7 +1117,7 @@ dependencies = [ [[package]] name = "bevy_internal" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_a11y", "bevy_android", @@ -1177,7 +1176,7 @@ dependencies = [ [[package]] name = "bevy_light" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1201,7 +1200,7 @@ dependencies = [ [[package]] name = "bevy_log" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "android_log-sys", "bevy_app", @@ -1218,7 +1217,7 @@ dependencies = [ [[package]] name = "bevy_macro_utils" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "proc-macro2", "quote", @@ -1229,7 +1228,7 @@ dependencies = [ [[package]] name = "bevy_material" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_asset", "bevy_derive", @@ -1251,7 +1250,7 @@ dependencies = [ [[package]] name = "bevy_material_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -1261,7 +1260,7 @@ dependencies = [ [[package]] name = "bevy_math" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "approx", "arrayvec", @@ -1280,7 +1279,7 @@ dependencies = [ [[package]] name = "bevy_mesh" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1329,7 +1328,6 @@ checksum = "bff34eb29ff4b8a8688bc7299f14fb6b597461ca80fec03ed7d22939ab33e48f" [[package]] name = "bevy_naga_reflect" version = "0.1.0" -source = "git+https://github.com/tychedelia/bevy_naga_reflect#60010545e20027c7ae2ca084b21ce014664ccd36" dependencies = [ "bevy", "naga", @@ -1338,7 +1336,7 @@ dependencies = [ [[package]] name = "bevy_pbr" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "arrayvec", "bevy_app", @@ -1380,7 +1378,7 @@ dependencies = [ [[package]] name = "bevy_picking" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1403,7 +1401,7 @@ dependencies = [ [[package]] name = "bevy_platform" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "critical-section", "foldhash 0.2.0", @@ -1418,13 +1416,13 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-futures", "web-time", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] name = "bevy_post_process" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1448,12 +1446,12 @@ dependencies = [ [[package]] name = "bevy_ptr" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" [[package]] name = "bevy_reflect" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "assert_type_match", "bevy_platform", @@ -1481,7 +1479,7 @@ dependencies = [ [[package]] name = "bevy_reflect_derive" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "indexmap", @@ -1494,7 +1492,7 @@ dependencies = [ [[package]] name = "bevy_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "bevy_app", @@ -1547,7 +1545,7 @@ dependencies = [ [[package]] name = "bevy_render_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -1558,7 +1556,7 @@ dependencies = [ [[package]] name = "bevy_scene" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1577,7 +1575,7 @@ dependencies = [ [[package]] name = "bevy_scene_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "proc-macro2", @@ -1588,7 +1586,7 @@ dependencies = [ [[package]] name = "bevy_shader" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_asset", "bevy_platform", @@ -1607,7 +1605,7 @@ dependencies = [ [[package]] name = "bevy_sprite" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1632,7 +1630,7 @@ dependencies = [ [[package]] name = "bevy_sprite_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1664,7 +1662,7 @@ dependencies = [ [[package]] name = "bevy_state" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1679,7 +1677,7 @@ dependencies = [ [[package]] name = "bevy_state_macros" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_macro_utils", "quote", @@ -1689,7 +1687,7 @@ dependencies = [ [[package]] name = "bevy_tasks" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "async-executor", @@ -1707,7 +1705,7 @@ dependencies = [ [[package]] name = "bevy_text" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1735,7 +1733,7 @@ dependencies = [ [[package]] name = "bevy_time" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1749,7 +1747,7 @@ dependencies = [ [[package]] name = "bevy_transform" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_ecs", @@ -1765,7 +1763,7 @@ dependencies = [ [[package]] name = "bevy_ui" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -1802,7 +1800,7 @@ dependencies = [ [[package]] name = "bevy_ui_render" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1834,7 +1832,7 @@ dependencies = [ [[package]] name = "bevy_ui_widgets" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "bevy_a11y", @@ -1857,7 +1855,7 @@ dependencies = [ [[package]] name = "bevy_utils" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "async-channel", "bevy_platform", @@ -1869,7 +1867,7 @@ dependencies = [ [[package]] name = "bevy_window" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -1887,7 +1885,7 @@ dependencies = [ [[package]] name = "bevy_winit" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "accesskit", "accesskit_winit", @@ -1919,7 +1917,7 @@ dependencies = [ [[package]] name = "bevy_world_serialization" version = "0.19.0-dev" -source = "git+https://github.com/processing/bevy?branch=main#d53cefeaa9802a28c9b7e40f70489e4d817cb9eb" +source = "git+https://github.com/processing/bevy?branch=main#ee443e512e8d8e796a89d91f019c7e37054b8011" dependencies = [ "bevy_app", "bevy_asset", @@ -2023,9 +2021,9 @@ dependencies = [ [[package]] name = "blake3" -version = "1.8.4" +version = "1.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d2d5991425dfd0785aed03aedcf0b321d61975c9b5b3689c774a2610ae0b51e" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" dependencies = [ "arrayref", "arrayvec", @@ -2178,9 +2176,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.60" +version = "1.2.61" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" dependencies = [ "find-msvc-tools", "jobserver", @@ -2721,9 +2719,9 @@ checksum = "0c87e182de0887fd5361989c677c4e8f5000cd9491d6d563161a8f3a5519fc7f" [[package]] name = "data-encoding" -version = "2.10.0" +version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" +checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" [[package]] name = "derive_more" @@ -3328,7 +3326,7 @@ dependencies = [ "vec_map", "wasm-bindgen", "web-sys", - "windows 0.62.2", + "windows 0.56.0", ] [[package]] @@ -3464,7 +3462,7 @@ dependencies = [ "log", "presser", "thiserror 2.0.18", - "windows 0.62.2", + "windows 0.56.0", ] [[package]] @@ -4156,9 +4154,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" @@ -5734,6 +5732,7 @@ dependencies = [ "processing", "processing_cuda", "processing_glfw", + "processing_render", "processing_webcam", "pyo3", "rand 0.10.1", @@ -5754,7 +5753,6 @@ dependencies = [ "objc2 0.6.4", "objc2-app-kit 0.3.2", "processing_core", - "processing_midi", "raw-window-handle", "wasm-bindgen", "wasm-bindgen-futures", @@ -5814,7 +5812,7 @@ checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" [[package]] name = "pyo3" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "inventory", "libc", @@ -5828,7 +5826,7 @@ dependencies = [ [[package]] name = "pyo3-build-config" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "target-lexicon", ] @@ -5836,7 +5834,7 @@ dependencies = [ [[package]] name = "pyo3-ffi" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "libc", "pyo3-build-config", @@ -5845,7 +5843,7 @@ dependencies = [ [[package]] name = "pyo3-introspection" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "anyhow", "goblin", @@ -5856,7 +5854,7 @@ dependencies = [ [[package]] name = "pyo3-macros" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "proc-macro2", "pyo3-macros-backend", @@ -5867,7 +5865,7 @@ dependencies = [ [[package]] name = "pyo3-macros-backend" version = "0.28.3" -source = "git+https://github.com/PyO3/pyo3?branch=main#999560aadb5d4d4bdb10670169fc9294663a6313" +source = "git+https://github.com/PyO3/pyo3?branch=main#20781441337e84362bce32e43363a199a6182aab" dependencies = [ "heck", "proc-macro2", diff --git a/Cargo.toml b/Cargo.toml index a813652..e83186f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ too_many_arguments = "allow" [workspace.dependencies] bevy = { git = "https://github.com/processing/bevy", branch = "main", features = ["file_watcher", "shader_format_wesl", "free_camera", "pan_camera"] } -bevy_naga_reflect = { git = "https://github.com/tychedelia/bevy_naga_reflect" } +bevy_naga_reflect = { path = "../../tychedelia/bevy_naga_reflect" } bevy_cuda = { git = "https://github.com/tychedelia/bevy_cuda" } naga = { version = "29", features = ["wgsl-in"] } wesl = { version = "0.3", default-features = false } @@ -161,6 +161,54 @@ path = "examples/blend_modes.rs" name = "camera_controllers" path = "examples/camera_controllers.rs" +[[example]] +name = "compute_readback" +path = "examples/compute_readback.rs" + +[[example]] +name = "particles_basic" +path = "examples/particles_basic.rs" + +[[example]] +name = "particles_animated" +path = "examples/particles_animated.rs" + +[[example]] +name = "particles_oriented" +path = "examples/particles_oriented.rs" + +[[example]] +name = "particles_colored" +path = "examples/particles_colored.rs" + +[[example]] +name = "particles_colored_pbr" +path = "examples/particles_colored_pbr.rs" + +[[example]] +name = "particles_emit" +path = "examples/particles_emit.rs" + +[[example]] +name = "particles_lifecycle" +path = "examples/particles_lifecycle.rs" + +[[example]] +name = "particles_from_mesh" +path = "examples/particles_from_mesh.rs" + +[[example]] +name = "particles_noise" +path = "examples/particles_noise.rs" + +[[example]] +name = "particles_emit_gpu" +path = "examples/particles_emit_gpu.rs" + +[[example]] +name = "particles_stress" +path = "examples/particles_stress.rs" + [profile.wasm-release] inherits = "release" opt-level = "z" diff --git a/crates/processing_core/src/error.rs b/crates/processing_core/src/error.rs index 7cc1e9e..254b1f5 100644 --- a/crates/processing_core/src/error.rs +++ b/crates/processing_core/src/error.rs @@ -32,8 +32,8 @@ pub enum ProcessingError { TransformNotFound, #[error("Material not found")] MaterialNotFound, - #[error("Unknown material property: {0}")] - UnknownMaterialProperty(String), + #[error("Unknown shader property: {0}")] + UnknownShaderProperty(String), #[error("GLTF load error: {0}")] GltfLoadError(String), #[error("Webcam not connected")] @@ -46,4 +46,16 @@ pub enum ProcessingError { MidiPortNotFound(usize), #[error("CUDA error: {0}")] CudaError(String), + #[error("Compute shader not found")] + ComputeNotFound, + #[error("Buffer not found")] + BufferNotFound, + #[error("Buffer map error: {0}")] + BufferMapError(String), + #[error("Pipeline compile error: {0}")] + PipelineCompileError(String), + #[error("Pipeline not ready after {0} frames")] + PipelineNotReady(u32), + #[error("Particles not found")] + ParticlesNotFound, } diff --git a/crates/processing_ffi/src/lib.rs b/crates/processing_ffi/src/lib.rs index 7c76491..d2b9d24 100644 --- a/crates/processing_ffi/src/lib.rs +++ b/crates/processing_ffi/src/lib.rs @@ -10,6 +10,12 @@ use crate::color::Color; mod color; mod error; +unsafe fn cstr_to_str<'a>(ptr: *const std::ffi::c_char) -> Result<&'a str, ProcessingError> { + unsafe { std::ffi::CStr::from_ptr(ptr) } + .to_str() + .map_err(|_| ProcessingError::InvalidArgument("non-UTF8 C string".to_string())) +} + /// Initialize libProcessing. /// /// SAFETY: @@ -1776,12 +1782,12 @@ pub unsafe extern "C" fn processing_material_set_float( value: f32, ) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); error::check(|| { + let name = unsafe { cstr_to_str(name) }?; material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float(value), + shader_value::ShaderValue::Float(value), ) }); } @@ -1800,12 +1806,12 @@ pub unsafe extern "C" fn processing_material_set_float4( a: f32, ) { error::clear_error(); - let name = unsafe { std::ffi::CStr::from_ptr(name) }.to_str().unwrap(); error::check(|| { + let name = unsafe { cstr_to_str(name) }?; material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float4([r, g, b, a]), + shader_value::ShaderValue::Float4([r, g, b, a]), ) }); } @@ -1824,6 +1830,172 @@ pub extern "C" fn processing_material(window_id: u64, mat_id: u64) { error::check(|| graphics_record_command(window_entity, DrawCommand::Material(mat_entity))); } +// Shader + +/// Create a shader from WGSL source. +/// +/// # Safety +/// - `source` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_create(source: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let source = unsafe { cstr_to_str(source) }?; + shader_create(source) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Load a shader from a file path. +/// +/// # Safety +/// - `path` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_shader_load(path: *const std::ffi::c_char) -> u64 { + error::clear_error(); + error::check(|| { + let path = unsafe { cstr_to_str(path) }?; + shader_load(path) + }) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_shader_destroy(shader_id: u64) { + error::clear_error(); + error::check(|| shader_destroy(Entity::from_bits(shader_id))); +} + +// Buffer + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_create(size: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_create(size)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Create a buffer initialized with data. +/// +/// # Safety +/// - `data` must point to `len` valid bytes +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_create_with_data(data: *const u8, len: u64) -> u64 { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_create_with_data(bytes)) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Write data to a buffer. +/// +/// # Safety +/// - `data` must point to `len` valid bytes +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_write(buf_id: u64, data: *const u8, len: u64) { + error::clear_error(); + let bytes = unsafe { std::slice::from_raw_parts(data, len as usize) }.to_vec(); + error::check(|| buffer_write(Entity::from_bits(buf_id), bytes)); +} + +/// Returns the byte length of a buffer, or 0 if the buffer does not exist +/// (in which case the error is set). +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_size(buf_id: u64) -> u64 { + error::clear_error(); + error::check(|| buffer_size(Entity::from_bits(buf_id))).unwrap_or(0) +} + +/// Read buffer contents into a caller-provided buffer. +/// +/// # Safety +/// - `out` must be valid for writes of `out_len` bytes (may be null if +/// `out_len == 0`, in which case this acts as a size query). +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_buffer_read(buf_id: u64, out: *mut u8, out_len: u64) -> u64 { + error::clear_error(); + let Some(data) = error::check(|| buffer_read(Entity::from_bits(buf_id))) else { + return 0; + }; + let needed = data.len() as u64; + if needed <= out_len { + unsafe { std::ptr::copy_nonoverlapping(data.as_ptr(), out, data.len()) }; + } + needed +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_buffer_destroy(buf_id: u64) { + error::clear_error(); + error::check(|| buffer_destroy(Entity::from_bits(buf_id))); +} + +// Compute + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_create(shader_id: u64) -> u64 { + error::clear_error(); + error::check(|| compute_create(Entity::from_bits(shader_id))) + .map(|e| e.to_bits()) + .unwrap_or(0) +} + +/// Set a float property on a compute shader. +/// +/// # Safety +/// - `name` must be non-null +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_compute_set_float( + compute_id: u64, + name: *const std::ffi::c_char, + value: f32, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + compute_set( + Entity::from_bits(compute_id), + name, + shader_value::ShaderValue::Float(value), + ) + }); +} + +/// # Safety +/// `name` must be a valid null-terminated C string. +#[unsafe(no_mangle)] +pub unsafe extern "C" fn processing_compute_set_buffer( + compute_id: u64, + name: *const std::ffi::c_char, + buf_id: u64, +) { + error::clear_error(); + error::check(|| { + let name = unsafe { cstr_to_str(name) }?; + compute_set( + Entity::from_bits(compute_id), + name, + shader_value::ShaderValue::Buffer(Entity::from_bits(buf_id)), + ) + }); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_dispatch(compute_id: u64, x: u32, y: u32, z: u32) { + error::clear_error(); + error::check(|| compute_dispatch(Entity::from_bits(compute_id), x, y, z)); +} + +#[unsafe(no_mangle)] +pub extern "C" fn processing_compute_destroy(compute_id: u64) { + error::clear_error(); + error::check(|| compute_destroy(Entity::from_bits(compute_id))); +} + // Mouse buttons pub const PROCESSING_MOUSE_LEFT: u8 = 0; pub const PROCESSING_MOUSE_MIDDLE: u8 = 1; diff --git a/crates/processing_pyo3/Cargo.toml b/crates/processing_pyo3/Cargo.toml index 13b7423..f719f5c 100644 --- a/crates/processing_pyo3/Cargo.toml +++ b/crates/processing_pyo3/Cargo.toml @@ -21,6 +21,7 @@ cuda = ["dep:processing_cuda", "processing_cuda/cuda", "processing/cuda"] [dependencies] pyo3 = { workspace = true, features = ["experimental-inspect", "multiple-pymethods"] } processing = { workspace = true } +processing_render = { workspace = true } processing_webcam = { workspace = true, optional = true } processing_glfw = { workspace = true } bevy = { workspace = true, features = ["file_watcher"] } diff --git a/crates/processing_pyo3/examples/compute.py b/crates/processing_pyo3/examples/compute.py new file mode 100644 index 0000000..26fb5e0 --- /dev/null +++ b/crates/processing_pyo3/examples/compute.py @@ -0,0 +1,106 @@ +import struct + +from mewnala import Graphics, Shader, Compute, Buffer + +g = Graphics.new_offscreen(1, 1, "", None) +g.begin_draw() + +shader = Shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(1) +fn main() { + output[0] = 1u; + output[1] = 2u; + output[2] = 3u; + output[3] = 4u; +} +""") + +buf = Buffer(size=16) +compute = Compute(shader) +compute.set(output=buf) +compute.dispatch(1, 1, 1) + +data = buf.read() +assert isinstance(data, bytes), f"expected bytes, got {type(data)}" +assert list(struct.unpack("<4I", data)) == [1, 2, 3, 4] +print("PASS") + + +buf2 = Buffer(data=[10.0, 20.0, 30.0, 40.0]) +assert len(buf2) == 4 +assert buf2[0] == 10.0 +assert buf2[-1] == 40.0 +assert buf2[1:3] == [20.0, 30.0] + +buf2[2] = 99.0 +assert buf2[2] == 99.0 + +buf2[0:2] = [111.0, 222.0] +assert buf2[0] == 111.0 +assert buf2[1] == 222.0 +print("PASS") + + +double_shader = Shader(""" +@group(0) @binding(0) +var data: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + data[id.x] = data[id.x] * 2.0; +} +""") + +buf3 = Buffer(data=[1.0, 2.0, 3.0, 4.0]) +compute3 = Compute(double_shader) +compute3.set(data=buf3) +compute3.dispatch(1, 1, 1) +assert buf3.read() == [2.0, 4.0, 6.0, 8.0] +print("PASS") + + +compute3.dispatch(1, 1, 1) +assert buf3.read() == [4.0, 8.0, 12.0, 16.0] +print("PASS") + + +wg_shader = Shader(""" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + output[id.x] = id.x + 1u; +} +""") + +buf5 = Buffer(size=32) +compute5 = Compute(wg_shader) +compute5.set(output=buf5) +compute5.dispatch(2, 1, 1) +assert list(struct.unpack("<8I", buf5.read())) == [1, 2, 3, 4, 5, 6, 7, 8] +print("PASS") + + +copy_shader = Shader(""" +@group(0) @binding(0) var src: array; +@group(0) @binding(1) var dst: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + dst[id.x] = src[id.x] * 10.0; +} +""") + +src_buf = Buffer(data=[1.0, 2.0, 3.0, 4.0]) +dst_buf = Buffer(size=16) +compute6 = Compute(copy_shader) +compute6.set(src=src_buf, dst=dst_buf) +compute6.dispatch(1, 1, 1) +assert list(struct.unpack("<4f", dst_buf.read())) == [10.0, 20.0, 30.0, 40.0] +print("PASS") + +g.end_draw() \ No newline at end of file diff --git a/crates/processing_pyo3/examples/particles_animated.py b/crates/processing_pyo3/examples/particles_animated.py new file mode 100644 index 0000000..26b209e --- /dev/null +++ b/crates/processing_pyo3/examples/particles_animated.py @@ -0,0 +1,71 @@ +from mewnala import * + +p = None +sphere = None +mat = None +spin = None + +SPIN_SHADER = """ +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +""" + + +def setup(): + global p, sphere, mat, spin + + size(900, 700) + mode_3d() + + directional_light((0.9, 0.85, 0.8), 300.0) + + sphere = Geometry.sphere(0.25, 12, 8) + + capacity = 1000 + positions = [] + for x in range(10): + for y in range(10): + for z in range(10): + positions.extend([x - 4.5, y - 4.5, z - 4.5]) + + p = Particles(capacity=capacity, attributes=[Attribute.position()]) + pos_buf = p.buffer(Attribute.position()) + pos_buf.write(positions) + + mat = Material(roughness=0.4) + spin = Compute(Shader(SPIN_SHADER)) + + +def draw(): + camera_position(0.0, 8.0, 25.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + fill(230, 128, 75) + + use_material(mat) + particles(p, sphere) + + spin.set(dt=0.01) + p.apply(spin) + + +run() diff --git a/crates/processing_pyo3/examples/particles_basic.py b/crates/processing_pyo3/examples/particles_basic.py new file mode 100644 index 0000000..d992b42 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_basic.py @@ -0,0 +1,44 @@ +from mewnala import * + +p = None +particle = None +mat = None + + +def setup(): + global p, particle, mat + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 600.0) + + source = Geometry.sphere(5.0, 32, 24) + p = Particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.pbr(albedo=color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + particles(p, particle) + + +run() diff --git a/crates/processing_pyo3/examples/particles_emit.py b/crates/processing_pyo3/examples/particles_emit.py new file mode 100644 index 0000000..210be17 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_emit.py @@ -0,0 +1,58 @@ +from mewnala import * +import math + +p = None +sphere = None +mat = None +frame = 0 + + +def setup(): + global p, sphere, mat + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.08, 8, 6) + + capacity = 2000 + p = Particles( + capacity=capacity, + attributes=[Attribute.position(), Attribute.color()], + ) + + # Park unemitted slots far off-screen until the ring buffer fills. + pos_buf = p.buffer(Attribute.position()) + pos_buf.write([1.0e6] * (capacity * 3)) + + color_buf = p.buffer(Attribute.color()) + mat = Material.unlit(albedo=color_buf) + + +def draw(): + global frame + camera_position(0.0, 4.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + particles(p, sphere) + + # Emit 4 particles per frame in an outward-spiraling ring. + burst = 4 + positions = [] + colors = [] + for k in range(burst): + i = frame * burst + k + t = i * 0.05 + radius = 1.5 + min(t * 0.02, 3.0) + height = math.sin(t * 0.1) * 2.0 + positions.extend([math.cos(t) * radius, height, math.sin(t) * radius]) + c = hsva((i * 4.32) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + p.emit(burst, position=positions, color=colors) + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/particles_emit_gpu.py b/crates/processing_pyo3/examples/particles_emit_gpu.py new file mode 100644 index 0000000..2b57d31 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_emit_gpu.py @@ -0,0 +1,180 @@ +from mewnala import * +import math + +p = None +particle = None +mat = None +spawn = None +motion = None + +CAPACITY = 40000 +BURST = 120 +DT = 1.0 / 60.0 +TTL = 2.5 +GRAVITY = 9.8 +SPEED = 5.0 + +SPAWN_SHADER = """ +struct Spawn { + pos: vec4, + speed: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var color: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var dead: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_range: vec4; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= u32(emit_range.y) { return; } + let base = u32(emit_range.x); + let cap = u32(emit_range.z); + let slot = (base + local_i) % cap; + + let seed = base + local_i; + + let theta = hash_unit(seed) * 6.2831853; + let r = sqrt(hash_unit(seed * 2u + 1u)); + let dirxz = vec2(cos(theta), sin(theta)) * r; + let dy = 0.7 + 0.3 * hash_unit(seed * 3u + 7u); + let v = vec3(dirxz.x, dy, dirxz.y) * spawn.speed.x; + + position[slot * 3u + 0u] = spawn.pos.x; + position[slot * 3u + 1u] = spawn.pos.y; + position[slot * 3u + 2u] = spawn.pos.z; + + velocity[slot * 3u + 0u] = v.x; + velocity[slot * 3u + 1u] = v.y; + velocity[slot * 3u + 2u] = v.z; + + let h = fract(hash_unit(seed * 5u + 11u)); + color[slot * 4u + 0u] = 0.5 + 0.5 * sin(h * 6.28); + color[slot * 4u + 1u] = 0.5 + 0.5 * sin(h * 6.28 + 2.094); + color[slot * 4u + 2u] = 0.5 + 0.5 * sin(h * 6.28 + 4.189); + color[slot * 4u + 3u] = 1.0; + + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + + age[slot] = 0.0; + dead[slot] = 0.0; +} +""" + +MOTION_SHADER = """ +struct Params { + dt: f32, + ttl: f32, + gravity: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var scale: array; +@group(0) @binding(3) var age: array; +@group(0) @binding(4) var dead: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + if dead[i] != 0.0 { return; } + + age[i] = age[i] + params.dt; + + velocity[i * 3u + 1u] = velocity[i * 3u + 1u] - params.gravity * params.dt; + + position[i * 3u + 0u] = position[i * 3u + 0u] + velocity[i * 3u + 0u] * params.dt; + position[i * 3u + 1u] = position[i * 3u + 1u] + velocity[i * 3u + 1u] * params.dt; + position[i * 3u + 2u] = position[i * 3u + 2u] + velocity[i * 3u + 2u] * params.dt; + + let life = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { dead[i] = 1.0; } +} +""" + + +def setup(): + global p, particle, mat, spawn, motion + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 800.0) + + particle = Geometry.sphere(0.12, 8, 6) + + velocity_attr = Attribute("velocity", AttributeFormat.Float3) + age_attr = Attribute("age", AttributeFormat.Float) + + p = Particles( + capacity=CAPACITY, + attributes=[ + Attribute.position(), + Attribute.color(), + Attribute.scale(), + Attribute.dead(), + velocity_attr, + age_attr, + ], + ) + + # Park unemitted slots until the spawn kernel fills them. + dead_buf = p.buffer(Attribute.dead()) + dead_buf.write([1.0] * CAPACITY) + + color_buf = p.buffer(Attribute.color()) + mat = Material.pbr(albedo=color_buf) + + spawn = Compute(Shader(SPAWN_SHADER)) + motion = Compute(Shader(MOTION_SHADER)) + + +def draw(): + camera_position(0.0, 4.0, 16.0) + camera_look_at(0.0, 2.0, 0.0) + background(10, 10, 18) + + use_material(mat) + particles(p, particle) + + t = elapsed_time + sx = math.cos(t) * 0.4 + sz = math.sin(t) * 0.4 + spawn.set(pos=[sx, 7.0, sz, 0.0], speed=[SPEED, 0.0, 0.0, 0.0]) + p.emit_gpu(BURST, spawn) + + motion.set(dt=DT, ttl=TTL, gravity=GRAVITY) + p.apply(motion) + + +run() diff --git a/crates/processing_pyo3/examples/particles_from_mesh.py b/crates/processing_pyo3/examples/particles_from_mesh.py new file mode 100644 index 0000000..4c9f762 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_from_mesh.py @@ -0,0 +1,44 @@ +from mewnala import * + +p = None +particle = None +mat = None + + +def setup(): + global p, particle, mat + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 200.0) + + source = Geometry.sphere(5.0, 32, 24) + p = Particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.pbr(albedo=color_buf) + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + particles(p, particle) + + +run() diff --git a/crates/processing_pyo3/examples/particles_lifecycle.py b/crates/processing_pyo3/examples/particles_lifecycle.py new file mode 100644 index 0000000..3439a16 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_lifecycle.py @@ -0,0 +1,124 @@ +from mewnala import * +import math + +p = None +sphere = None +mat = None +aging = None +position_attr = None +color_attr = None +scale_attr = None +dead_attr = None +age_attr = None +frame = 0 + +BURST = 6 +DT = 1.0 / 60.0 +TTL = 1.0 + +AGING_SHADER = """ +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var dead: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var params: vec4; // x = dt, y = ttl + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { + return; + } + let dt = params.x; + let ttl = params.y; + + if dead[i] != 0.0 { + return; + } + + age[i] = age[i] + dt; + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + dead[i] = 1.0; + } +} +""" + + +def setup(): + global p, sphere, mat, aging + global position_attr, color_attr, scale_attr, dead_attr, age_attr + + size(900, 700) + mode_3d() + + sphere = Geometry.sphere(0.1, 8, 6) + + capacity = 800 + position_attr = Attribute.position() + color_attr = Attribute.color() + scale_attr = Attribute.scale() + dead_attr = Attribute.dead() + age_attr = Attribute("age", AttributeFormat.Float) + + p = Particles( + capacity=capacity, + attributes=[position_attr, color_attr, scale_attr, dead_attr, age_attr], + ) + + # Park unemitted slots until the spawn loop fills them. + dead_buf = p.buffer(dead_attr) + dead_buf.write([1.0] * capacity) + + color_buf = p.buffer(color_attr) + mat = Material.unlit(albedo=color_buf) + aging = Compute(Shader(AGING_SHADER)) + + +def draw(): + global frame + camera_position(0.0, 2.0, 14.0) + camera_look_at(0.0, 0.0, 0.0) + background(10, 10, 18) + + use_material(mat) + particles(p, sphere) + + positions = [] + colors = [] + for k in range(BURST): + i = frame * BURST + k + u = (((i * 2654435761) >> 8) & 0xFFFF) / 65535.0 + v = (((i * 40503) >> 8) & 0xFFFF) / 65535.0 + theta = u * math.tau + r = v * 0.6 + positions.extend([math.cos(theta) * r, 2.5, math.sin(theta) * r]) + c = hsva((i * 4.68) % 360.0, 0.85, 1.0) + colors.extend([c.r, c.g, c.b, 1.0]) + + zeros = [0.0] * BURST + ones_scale = [1.0] * (BURST * 3) + p.emit( + BURST, + position=positions, + color=colors, + scale=ones_scale, + age=zeros, + dead=zeros, + ) + + aging.set(params=[DT, TTL, 0.0, 0.0]) + p.apply(aging) + + frame += 1 + + +run() diff --git a/crates/processing_pyo3/examples/particles_noise.py b/crates/processing_pyo3/examples/particles_noise.py new file mode 100644 index 0000000..c32bbb1 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_noise.py @@ -0,0 +1,49 @@ +from mewnala import * + +p = None +particle = None +mat = None +noise = None + + +def setup(): + global p, particle, mat, noise + + size(900, 700) + mode_3d() + + directional_light((0.95, 0.9, 0.85), 200.0) + + source = Geometry.sphere(5.0, 32, 24) + p = Particles( + geometry=source, + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + uv_buf = p.buffer(Attribute.uv()) + color_buf = p.buffer(Attribute.color()) + + colors = [] + for uv in uv_buf.read(): + c = hsva(uv[0] * 360.0, 0.85, 1.0) + colors.append([c.r, c.g, c.b, 1.0]) + color_buf.write(colors) + + particle = Geometry.sphere(0.18, 10, 8) + mat = Material.pbr(albedo=color_buf) + noise = kernel_noise() + + +def draw(): + camera_position(0.0, 4.0, 18.0) + camera_look_at(0.0, 0.0, 0.0) + background(15, 15, 20) + + use_material(mat) + particles(p, particle) + + noise.set(scale=0.25, strength=0.02, time=elapsed_time * 0.5) + p.apply(noise) + + +run() diff --git a/crates/processing_pyo3/examples/particles_stress.py b/crates/processing_pyo3/examples/particles_stress.py new file mode 100644 index 0000000..1f8c517 --- /dev/null +++ b/crates/processing_pyo3/examples/particles_stress.py @@ -0,0 +1,51 @@ +from mewnala import * + +GRID = 150 +SPACING = 1.0 +SPIN_PER_FRAME = 0.003 + +p = None +cube = None +spin = None + + +def setup(): + global p, cube, spin + + size(900, 700) + mode_3d() + + extent = GRID * SPACING * 0.5 + camera_position(0.0, extent * 0.6, extent * 2.5) + camera_look_at(0.0, 0.0, 0.0) + orbit_camera() + + directional_light((1.0, 0.0, 0.0), 1000.0, position=Vec3.X, look_at=Vec3.ZERO) + directional_light((0.0, 1.0, 0.0), 1000.0, position=Vec3.Y, look_at=Vec3.ZERO) + directional_light((0.0, 0.0, 1.0), 1000.0, position=Vec3.Z, look_at=Vec3.ZERO) + + p = Particles( + geometry=Geometry.grid(GRID, GRID, GRID, SPACING), + attributes=[Attribute.position(), Attribute.uv(), Attribute.color()], + ) + + p.apply(kernel_noise(), scale=1.0 / SPACING, strength=SPACING * 0.6) + + color_buf = p.buffer(Attribute.color()) + color_buf.write([ + [c.r, c.g, c.b, 1.0] + for uv in p.buffer(Attribute.uv()).read() + for c in [hsva(uv[0] * 360.0, 0.85, 1.0)] + ]) + + fill(color_buf) + cube = Geometry.box(0.35, 0.35, 0.35) + spin = kernel_transform() + + +def draw(): + background(10, 10, 18) + particles(p, cube) + p.apply(spin, rotation_axis=Vec3.Y, rotation_angle=SPIN_PER_FRAME) + +run() diff --git a/crates/processing_pyo3/mewnala/__init__.py b/crates/processing_pyo3/mewnala/__init__.py index 13b1b81..e76c60a 100644 --- a/crates/processing_pyo3/mewnala/__init__.py +++ b/crates/processing_pyo3/mewnala/__init__.py @@ -1,11 +1,11 @@ from .mewnala import * # re-export the native submodules as submodules of this module, if they exist -# this allows users to import from `mewnala.math` and `mewnala.color` -# if they exist, without needing to know about the internal structure of the native module +# this allows users to import from `mewnala.math` without needing to know about +# the internal structure of the native module import sys as _sys from . import mewnala as _native -for _name in ("math", "color"): +for _name in ("math",): _sub = getattr(_native, _name, None) if _sub is not None: _sys.modules[f"{__name__}.{_name}"] = _sub diff --git a/crates/processing_pyo3/src/compute.rs b/crates/processing_pyo3/src/compute.rs new file mode 100644 index 0000000..98d7570 --- /dev/null +++ b/crates/processing_pyo3/src/compute.rs @@ -0,0 +1,314 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use pyo3::{ + exceptions::{PyIndexError, PyRuntimeError, PyTypeError, PyValueError}, + prelude::*, + types::{PyBytes, PyList, PySlice, PySliceIndices}, +}; + +use shader_value::ShaderValue; + +use crate::material::py_to_shader_value; +use crate::shader::Shader; + +#[pyclass(unsendable)] +pub struct Buffer { + pub(crate) entity: Entity, + element_type: Option, + size: u64, + /// `true` for borrowed wrappers (e.g. `Particles.buffer()`) where the + /// underlying entity belongs elsewhere; `Drop` skips destroy in that case. + borrowed: bool, +} + +impl Buffer { + /// Wrap an existing buffer entity without taking ownership. `Drop` will + /// not destroy it. + pub(crate) fn from_entity(entity: Entity, element_type: Option) -> Self { + let size = buffer_size(entity).unwrap_or(0); + Self { + entity, + element_type, + size, + borrowed: true, + } + } +} + +#[pymethods] +impl Buffer { + #[new] + #[pyo3(signature = (size=None, data=None))] + pub fn new(size: Option, data: Option<&Bound<'_, PyAny>>) -> PyResult { + let (entity, size, element_type) = if let Some(data) = data { + let (bytes, element_type) = shader_values_to_bytes(data)?; + let size = bytes.len() as u64; + let entity = buffer_create_with_data(bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, element_type) + } else { + let size = size.unwrap_or(0); + let entity = + buffer_create(size).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + (entity, size, None) + }; + Ok(Self { + entity, + element_type, + size, + borrowed: false, + }) + } + + pub fn __len__(&self) -> usize { + match &self.element_type { + Some(et) => et + .byte_size() + .map(|s| self.size as usize / s) + .unwrap_or(self.size as usize), + None => self.size as usize, + } + } + + pub fn __getitem__(&self, py: Python<'_>, index: &Bound<'_, PyAny>) -> PyResult> { + let Some(ref et) = self.element_type else { + return Err(PyTypeError::new_err("no element type; write values first")); + }; + let elem_size = et.byte_size().unwrap() as u64; + + let read = |i: isize| -> PyResult> { + let bytes = buffer_read_element(self.entity, i as u64 * elem_size, elem_size) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let sv = et + .read_from_bytes(&bytes) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode element"))?; + shader_value_to_py(py, &sv) + }; + + if let Ok(i) = index.extract::() { + Ok(read(self.normalize_index(i)? as isize)?.into()) + } else if let Ok(slice) = index.cast::() { + let indices = slice.indices(self.__len__() as isize)?; + let values = slice_positions(&indices) + .map(read) + .collect::>>()?; + Ok(PyList::new(py, values)?.into()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn __setitem__( + &mut self, + index: &Bound<'_, PyAny>, + value: &Bound<'_, PyAny>, + ) -> PyResult<()> { + if let Ok(i) = index.extract::() { + let sv = py_to_shader_value(value)?; + self.check_element_type(&sv)?; + let bytes = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = bytes.len() as u64; + let i = self.normalize_index(i)?; + buffer_write_element(self.entity, i as u64 * elem_size, bytes) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } else if let Ok(slice) = index.cast::() { + let (src_bytes, element_type) = shader_values_to_bytes(value)?; + let et = element_type + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + let elem_size = et.byte_size().unwrap() as u64; + self.check_element_type(&et)?; + let indices = slice.indices(self.__len__() as isize)?; + let src_elems = src_bytes.len() as u64 / elem_size; + if indices.slicelength as u64 != src_elems { + return Err(PyValueError::new_err(format!( + "slice length {} does not match value length {}", + indices.slicelength, src_elems + ))); + } + for (pos, chunk) in + slice_positions(&indices).zip(src_bytes.chunks_exact(elem_size as usize)) + { + buffer_write_element(self.entity, pos as u64 * elem_size, chunk.to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } else { + Err(PyTypeError::new_err("index must be int or slice")) + } + } + + pub fn write(&mut self, values: &Bound<'_, PyAny>) -> PyResult<()> { + // Bytes path skips per-element conversion — the only viable route for + // multi-million-element uploads. + if let Ok(b) = values.cast::() { + return buffer_write(self.entity, b.as_bytes().to_vec()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + let (bytes, element_type) = shader_values_to_bytes(values)?; + self.element_type = element_type; + buffer_write(self.entity, bytes).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + pub fn read<'py>(&mut self, py: Python<'py>) -> PyResult> { + let data = buffer_read(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + + let Some(ref template) = self.element_type else { + return Ok(PyBytes::new(py, &data).into_any()); + }; + + let elem_size = template + .byte_size() + .ok_or_else(|| PyRuntimeError::new_err("unsupported element type"))?; + + let values = data + .chunks_exact(elem_size) + .map(|chunk| { + let sv = template + .read_from_bytes(chunk) + .ok_or_else(|| PyRuntimeError::new_err("failed to decode bytes"))?; + shader_value_to_py(py, &sv) + }) + .collect::>>()?; + + Ok(PyList::new(py, values)?.into_any()) + } +} + +impl Buffer { + fn check_element_type(&mut self, sv: &ShaderValue) -> PyResult<()> { + match &self.element_type { + Some(existing) if std::mem::discriminant(existing) != std::mem::discriminant(sv) => { + Err(PyTypeError::new_err(format!( + "buffer element type mismatch: expected {existing:?}, got {sv:?}" + ))) + } + Some(_) => Ok(()), + None => { + self.element_type = Some(sv.clone()); + Ok(()) + } + } + } + + fn normalize_index(&self, i: isize) -> PyResult { + let len = self.__len__() as isize; + let i = if i < 0 { len + i } else { i }; + if i < 0 || i >= len { + Err(PyIndexError::new_err("buffer index out of range")) + } else { + Ok(i as usize) + } + } +} + +impl Drop for Buffer { + fn drop(&mut self) { + if !self.borrowed { + let _ = buffer_destroy(self.entity); + } + } +} + +fn slice_positions(indices: &PySliceIndices) -> impl Iterator + use<> { + let PySliceIndices { + start, + step, + slicelength, + .. + } = *indices; + (0..slicelength as isize).map(move |i| start + i * step) +} + +fn shader_values_to_bytes(values: &Bound<'_, PyAny>) -> PyResult<(Vec, Option)> { + let mut bytes = Vec::new(); + let mut element_type: Option = None; + for item in values.try_iter()? { + let sv = py_to_shader_value(&item?)?; + if let Some(ref existing) = element_type + && std::mem::discriminant(existing) != std::mem::discriminant(&sv) + { + return Err(PyTypeError::new_err(format!( + "buffer elements must all share the same type: expected {existing:?}, got {sv:?}" + ))); + } + let b = sv + .to_bytes() + .ok_or_else(|| PyTypeError::new_err("unsupported value type for buffer"))?; + element_type.get_or_insert(sv); + bytes.extend_from_slice(&b); + } + Ok((bytes, element_type)) +} + +fn shader_value_to_py<'py>(py: Python<'py>, sv: &ShaderValue) -> PyResult> { + fn list<'py, T: pyo3::IntoPyObject<'py> + Copy>( + py: Python<'py>, + xs: &[T], + ) -> PyResult> { + Ok(PyList::new(py, xs.iter().copied())?.into_any()) + } + match sv { + ShaderValue::Float(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Int(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::UInt(v) => Ok(v.into_pyobject(py)?.into_any()), + ShaderValue::Float2(v) => list(py, v), + ShaderValue::Float3(v) => list(py, v), + ShaderValue::Float4(v) => list(py, v), + ShaderValue::Int2(v) => list(py, v), + ShaderValue::Int3(v) => list(py, v), + ShaderValue::Int4(v) => list(py, v), + ShaderValue::Mat4(v) => list(py, v), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => Err(PyRuntimeError::new_err( + "cannot convert Texture/Buffer to Python value", + )), + } +} + +#[pyclass(unsendable)] +pub struct Compute { + pub(crate) entity: Entity, +} + +impl Compute { + /// Wrap an existing compute entity (e.g., one created by a Rust-side + /// factory like `field_kernel_noise`). Not exposed to Python directly. + pub(crate) fn from_entity(entity: Entity) -> Self { + Self { entity } + } +} + +#[pymethods] +impl Compute { + #[new] + pub fn new(shader: &Shader) -> PyResult { + let entity = + compute_create(shader.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[pyo3(signature = (**kwargs))] + pub fn set(&self, kwargs: Option<&Bound<'_, pyo3::types::PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return Ok(()); + }; + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let value = py_to_shader_value(&value)?; + compute_set(self.entity, &name, value) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) + } + + pub fn dispatch(&self, x: u32, y: u32, z: u32) -> PyResult<()> { + compute_dispatch(self.entity, x, y, z).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Compute { + fn drop(&mut self) { + let _ = compute_destroy(self.entity); + } +} diff --git a/crates/processing_pyo3/src/graphics.rs b/crates/processing_pyo3/src/graphics.rs index 2da0e77..b2ae3be 100644 --- a/crates/processing_pyo3/src/graphics.rs +++ b/crates/processing_pyo3/src/graphics.rs @@ -369,6 +369,35 @@ impl Geometry { pub fn vertex_count(&self) -> PyResult { geometry_vertex_count(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + + /// Retained sphere mesh. + #[staticmethod] + #[pyo3(signature = (radius, sectors=32, stacks=18))] + pub fn sphere(radius: f32, sectors: u32, stacks: u32) -> PyResult { + let entity = geometry_sphere(radius, sectors, stacks) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + /// Retained box mesh. + #[staticmethod] + pub fn r#box(width: f32, height: f32, depth: f32) -> PyResult { + let entity = geometry_box(width, height, depth) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + /// 3D lattice of `nx * ny * nz` points centered at the origin, with + /// `spacing` units between adjacent points. Topology is `PointList` — + /// typically used as a position source for `Particles(geometry=...)` rather + /// than rasterized directly. + #[staticmethod] + #[pyo3(signature = (nx, ny, nz, spacing=1.0))] + pub fn grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> PyResult { + let entity = geometry_grid(nx, ny, nz, spacing) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } } #[pyclass(unsendable)] @@ -561,6 +590,12 @@ impl Graphics { #[pyo3(signature = (*args))] pub fn fill(&self, args: &Bound<'_, PyTuple>) -> PyResult<()> { + if args.len() == 1 + && let Ok(buf) = args.get_item(0)?.extract::>() + { + return graphics_record_command(self.entity, DrawCommand::FillBuffer(buf.entity)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } let color = extract_color_with_mode( args, &graphics_get_color_mode(self.entity) @@ -1151,6 +1186,21 @@ impl Graphics { .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) } + pub fn particles( + &self, + particles: &crate::particles::Particles, + geometry: &Geometry, + ) -> PyResult<()> { + graphics_record_command( + self.entity, + DrawCommand::Particles { + particles: particles.entity, + geometry: geometry.entity, + }, + ) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + pub fn use_material(&self, material: &crate::material::Material) -> PyResult<()> { graphics_record_command(self.entity, DrawCommand::Material(material.entity)) .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) diff --git a/crates/processing_pyo3/src/lib.rs b/crates/processing_pyo3/src/lib.rs index cf45129..8a3a546 100644 --- a/crates/processing_pyo3/src/lib.rs +++ b/crates/processing_pyo3/src/lib.rs @@ -9,8 +9,10 @@ //! To allow Python users to create a similar experience, we provide module-level //! functions that forward to a singleton Graphics object pub(crate) behind the scenes. pub(crate) mod color; +pub(crate) mod compute; #[cfg(feature = "cuda")] pub(crate) mod cuda; +pub(crate) mod particles; mod glfw; mod gltf; mod graphics; @@ -25,6 +27,7 @@ mod time; #[cfg(feature = "webcam")] mod webcam; +use compute::{Buffer, Compute}; use graphics::{ Geometry, Graphics, Image, Light, PyBlendMode, Sampler, Topology, get_graphics, get_graphics_mut, @@ -326,6 +329,18 @@ fn detect_environment(py: Python<'_>) -> PyResult { mod mewnala { use super::*; + #[pymodule_export] + use super::Buffer; + #[pymodule_export] + use super::color::PyColor; + #[pymodule_export] + use super::Compute; + #[pymodule_export] + use super::particles::Attribute; + #[pymodule_export] + use super::particles::AttributeFormat; + #[pymodule_export] + use super::particles::Particles; #[pymodule_export] use super::Geometry; #[pymodule_export] @@ -339,6 +354,14 @@ mod mewnala { #[pymodule_export] use super::Material; #[pymodule_export] + use super::math::PyQuat; + #[pymodule_export] + use super::math::PyVec2; + #[pymodule_export] + use super::math::PyVec3; + #[pymodule_export] + use super::math::PyVec4; + #[pymodule_export] use super::PyBlendMode; #[pymodule_export] use super::Sampler; @@ -666,90 +689,73 @@ mod mewnala { } } - #[pymodule] - mod color { - use super::*; - - #[pymodule_export] - use crate::color::PyColor; - - #[pyfunction(name = "color")] - #[pyo3(signature = (*args))] - fn color_ctor(py: Python<'_>, args: &Bound<'_, PyTuple>) -> PyResult { - let parent = py.import("mewnala.mewnala")?; - match get_graphics(&parent)? { - Some(g) => g.color(args), - None => { - let mode = crate::color::ColorMode::default(); - crate::color::extract_color_with_mode(args, &mode).map(PyColor::from) - } - } - } + // Color constructors — promoted to top-level so `from mewnala import *` + // exposes `hsva(...)`, `srgb(...)`, etc. directly. Living in a `color` + // submodule conflicted with the Processing-style `color()` function. - #[pyfunction] - fn hex(s: &str) -> PyResult { - PyColor::hex(s) - } + #[pyfunction] + fn color_hex(s: &str) -> PyResult { + PyColor::hex(s) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::srgb(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn srgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::srgb(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (r, g, b, a=1.0))] - fn linear(r: f32, g: f32, b: f32, a: f32) -> PyColor { - PyColor::linear(r, g, b, a) - } + #[pyfunction] + #[pyo3(signature = (r, g, b, a=1.0))] + fn linear_rgb(r: f32, g: f32, b: f32, a: f32) -> PyColor { + PyColor::linear(r, g, b, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, l, a=1.0))] - fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { - PyColor::hsla(h, s, l, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, l, a=1.0))] + fn hsla(h: f32, s: f32, l: f32, a: f32) -> PyColor { + PyColor::hsla(h, s, l, a) + } - #[pyfunction] - #[pyo3(signature = (h, s, v, a=1.0))] - fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { - PyColor::hsva(h, s, v, a) - } + #[pyfunction] + #[pyo3(signature = (h, s, v, a=1.0))] + fn hsva(h: f32, s: f32, v: f32, a: f32) -> PyColor { + PyColor::hsva(h, s, v, a) + } - #[pyfunction] - #[pyo3(signature = (h, w, b, a=1.0))] - fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { - PyColor::hwba(h, w, b, a) - } + #[pyfunction] + #[pyo3(signature = (h, w, b, a=1.0))] + fn hwba(h: f32, w: f32, b: f32, a: f32) -> PyColor { + PyColor::hwba(h, w, b, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::oklab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn oklab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::oklab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::oklch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn oklch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::oklch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] - fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { - PyColor::lab(l, a_axis, b_axis, alpha) - } + #[pyfunction] + #[pyo3(signature = (l, a_axis, b_axis, alpha=1.0))] + fn lab(l: f32, a_axis: f32, b_axis: f32, alpha: f32) -> PyColor { + PyColor::lab(l, a_axis, b_axis, alpha) + } - #[pyfunction] - #[pyo3(signature = (l, c, h, a=1.0))] - fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { - PyColor::lch(l, c, h, a) - } + #[pyfunction] + #[pyo3(signature = (l, c, h, a=1.0))] + fn lch(l: f32, c: f32, h: f32, a: f32) -> PyColor { + PyColor::lch(l, c, h, a) + } - #[pyfunction] - #[pyo3(signature = (x, y, z, a=1.0))] - fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { - PyColor::xyz(x, y, z, a) - } + #[pyfunction] + #[pyo3(signature = (x, y, z, a=1.0))] + fn xyz(x: f32, y: f32, z: f32, a: f32) -> PyColor { + PyColor::xyz(x, y, z, a) } #[cfg(feature = "webcam")] @@ -1309,6 +1315,45 @@ mod mewnala { graphics!(module).draw_geometry(&*geometry.extract::>()?) } + #[pyfunction] + #[pyo3(pass_module, signature = (particles, geometry))] + fn particles( + module: &Bound<'_, PyModule>, + particles: &Bound<'_, super::particles::Particles>, + geometry: &Bound<'_, Geometry>, + ) -> PyResult<()> { + graphics!(module).particles( + &*particles.extract::>()?, + &*geometry.extract::>()?, + ) + } + + #[pyfunction] + fn kernel_noise() -> PyResult { + super::particles::kernel_noise() + } + + #[pyfunction] + fn kernel_transform() -> PyResult { + super::particles::kernel_transform() + } + + #[pyfunction(name = "color")] + #[pyo3(pass_module, signature = (*args))] + fn create_color( + module: &Bound<'_, PyModule>, + args: &Bound<'_, PyTuple>, + ) -> PyResult { + match get_graphics(module)? { + Some(g) => g.color(args), + None => { + let mode = super::color::ColorMode::default(); + super::color::extract_color_with_mode(args, &mode).map(super::color::PyColor::from) + } + } + } + + #[pyfunction] #[pyo3(pass_module, signature = (*args))] fn background(module: &Bound<'_, PyModule>, args: &Bound<'_, PyTuple>) -> PyResult<()> { diff --git a/crates/processing_pyo3/src/material.rs b/crates/processing_pyo3/src/material.rs index a811851..4987d22 100644 --- a/crates/processing_pyo3/src/material.rs +++ b/crates/processing_pyo3/src/material.rs @@ -3,6 +3,8 @@ use processing::prelude::*; use pyo3::types::PyDict; use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use crate::color::PyColor; +use crate::compute::Buffer; use crate::graphics::ImageRef; use crate::math::{PyVec2, PyVec3, PyVec4}; use crate::shader::Shader; @@ -12,37 +14,39 @@ pub struct Material { pub(crate) entity: Entity, } -fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult { +pub(crate) fn py_to_shader_value(value: &Bound<'_, PyAny>) -> PyResult { if let Ok(img_ref) = value.extract::() { - return Ok(material::MaterialValue::Texture(img_ref.entity)); + return Ok(shader_value::ShaderValue::Texture(img_ref.entity)); } if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Float(v)); + return Ok(shader_value::ShaderValue::Float(v)); } if let Ok(v) = value.extract::() { - return Ok(material::MaterialValue::Int(v)); + return Ok(shader_value::ShaderValue::Int(v)); } - // Accept PyVec types if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float4(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float4(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float3(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float3(v.0.to_array())); } if let Ok(v) = value.extract::>() { - return Ok(material::MaterialValue::Float2(v.0.to_array())); + return Ok(shader_value::ShaderValue::Float2(v.0.to_array())); + } + + if let Ok(buf) = value.extract::>() { + return Ok(shader_value::ShaderValue::Buffer(buf.entity)); } - // Fall back to raw arrays if let Ok(v) = value.extract::<[f32; 4]>() { - return Ok(material::MaterialValue::Float4(v)); + return Ok(shader_value::ShaderValue::Float4(v)); } if let Ok(v) = value.extract::<[f32; 3]>() { - return Ok(material::MaterialValue::Float3(v)); + return Ok(shader_value::ShaderValue::Float3(v)); } if let Ok(v) = value.extract::<[f32; 2]>() { - return Ok(material::MaterialValue::Float2(v)); + return Ok(shader_value::ShaderValue::Float2(v)); } Err(PyRuntimeError::new_err(format!( @@ -51,8 +55,49 @@ fn py_to_material_value(value: &Bound<'_, PyAny>) -> PyResult) -> PyResult<()> { + if let Ok(buf) = value.extract::>() { + return material_set_albedo_buffer(entity, buf.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(c) = value.extract::>() { + let srgba: bevy::color::Srgba = c.0.into(); + return material_set_albedo_color(entity, [srgba.red, srgba.green, srgba.blue, srgba.alpha]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(rgba) = value.extract::<[f32; 4]>() { + return material_set_albedo_color(entity, rgba) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + if let Ok(rgb) = value.extract::<[f32; 3]>() { + return material_set_albedo_color(entity, [rgb[0], rgb[1], rgb[2], 1.0]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + } + Err(PyRuntimeError::new_err(format!( + "unsupported albedo type: {} (expected Color, Buffer, or [r,g,b,(a)])", + value.get_type().name()? + ))) +} + +fn apply_kwargs(entity: Entity, kwargs: &Bound<'_, PyDict>) -> PyResult<()> { + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + if name == "albedo" { + apply_albedo(entity, &value)?; + continue; + } + let v = py_to_shader_value(&value)?; + material_set(entity, &name, v).map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + } + Ok(()) +} + #[pymethods] impl Material { + /// No args: default PBR. With `shader`: custom material. Kwargs are + /// applied via `set` after construction. #[new] #[pyo3(signature = (shader=None, **kwargs))] pub fn new(shader: Option<&Shader>, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { @@ -63,30 +108,45 @@ impl Material { material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))? }; - let mat = Self { entity }; if let Some(kwargs) = kwargs { - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(mat.entity, &name, mat_value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - } + apply_kwargs(entity, kwargs)?; + } + Ok(Self { entity }) + } + + /// PBR-lit material. `albedo` accepts a `Color` or a `Buffer` (the latter + /// being per-particle, used with `Particles`). + #[staticmethod] + #[pyo3(signature = (**kwargs))] + pub fn pbr(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; } - Ok(mat) + Ok(Self { entity }) } + /// Like `pbr` but skips lighting; albedo is the final output color. + #[staticmethod] + #[pyo3(signature = (**kwargs))] + pub fn unlit(kwargs: Option<&Bound<'_, PyDict>>) -> PyResult { + let entity = material_create_pbr().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + material_set(entity, "unlit", shader_value::ShaderValue::Float(1.0)) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + if let Some(kwargs) = kwargs { + apply_kwargs(entity, kwargs)?; + } + Ok(Self { entity }) + } + + /// Patch material properties. `albedo` may swap the backing asset between + /// color and buffer variants; other StandardMaterial fields are preserved. #[pyo3(signature = (**kwargs))] pub fn set(&self, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { let Some(kwargs) = kwargs else { return Ok(()); }; - for (key, value) in kwargs.iter() { - let name: String = key.extract()?; - let mat_value = py_to_material_value(&value)?; - material_set(self.entity, &name, mat_value) - .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; - } - Ok(()) + apply_kwargs(self.entity, kwargs) } } diff --git a/crates/processing_pyo3/src/particles.rs b/crates/processing_pyo3/src/particles.rs new file mode 100644 index 0000000..77e7334 --- /dev/null +++ b/crates/processing_pyo3/src/particles.rs @@ -0,0 +1,265 @@ +use bevy::prelude::Entity; +use processing::prelude::*; +use processing_render::geometry as geometry; +use pyo3::types::PyDict; +use pyo3::{exceptions::PyRuntimeError, prelude::*}; +use std::collections::HashMap; + +use crate::compute::{Buffer, Compute}; +use crate::graphics::Geometry; + +/// Per-element format for an attribute. +#[pyclass(eq, eq_int)] +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum AttributeFormat { + Float = 1, + Float2 = 2, + Float3 = 3, + Float4 = 4, +} + +impl AttributeFormat { + pub(crate) fn to_inner(self) -> geometry::AttributeFormat { + match self { + Self::Float => geometry::AttributeFormat::Float, + Self::Float2 => geometry::AttributeFormat::Float2, + Self::Float3 => geometry::AttributeFormat::Float3, + Self::Float4 => geometry::AttributeFormat::Float4, + } + } + + pub(crate) fn from_inner(inner: geometry::AttributeFormat) -> Self { + match inner { + geometry::AttributeFormat::Float => Self::Float, + geometry::AttributeFormat::Float2 => Self::Float2, + geometry::AttributeFormat::Float3 => Self::Float3, + geometry::AttributeFormat::Float4 => Self::Float4, + } + } + + pub(crate) fn float_count(self) -> usize { + match self { + Self::Float => 1, + Self::Float2 => 2, + Self::Float3 => 3, + Self::Float4 => 4, + } + } +} + +/// Named typed attribute. Use the `position()`/`color()`/etc. classmethods for +/// builtins or `Attribute(name, format)` for custom ones. +#[pyclass(unsendable, frozen, hash, eq)] +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct Attribute { + pub(crate) entity: Entity, +} + +#[pymethods] +impl Attribute { + #[new] + pub fn new(name: &str, format: AttributeFormat) -> PyResult { + let entity = geometry_attribute_create(name, format.to_inner()) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Self { entity }) + } + + #[staticmethod] + pub fn position() -> Self { Self { entity: geometry_attribute_position() } } + #[staticmethod] + pub fn normal() -> Self { Self { entity: geometry_attribute_normal() } } + #[staticmethod] + pub fn color() -> Self { Self { entity: geometry_attribute_color() } } + #[staticmethod] + pub fn uv() -> Self { Self { entity: geometry_attribute_uv() } } + #[staticmethod] + pub fn rotation() -> Self { Self { entity: geometry_attribute_rotation() } } + #[staticmethod] + pub fn scale() -> Self { Self { entity: geometry_attribute_scale() } } + #[staticmethod] + pub fn dead() -> Self { Self { entity: geometry_attribute_dead() } } + + #[getter] + pub fn name(&self) -> PyResult { + let (name, _) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(name) + } + + #[getter] + pub fn format(&self) -> PyResult { + let (_, fmt) = geometry_attribute_info(self.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(AttributeFormat::from_inner(fmt)) + } +} + +#[pyclass(unsendable)] +pub struct Particles { + pub(crate) entity: Entity, + /// Name → (entity, format) so `emit(**kwargs)` can route kwargs to the + /// right attribute and pack them into bytes. + name_to_attr: HashMap, +} + +impl Particles { + fn build_name_index(attrs: &[Attribute]) -> PyResult> { + let mut map = HashMap::with_capacity(attrs.len()); + for attr in attrs { + let (name, fmt) = geometry_attribute_info(attr.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + map.insert(name, (attr.entity, AttributeFormat::from_inner(fmt))); + } + Ok(map) + } +} + +#[pymethods] +impl Particles { + /// Pass `capacity` for empty buffers, or `geometry` to seed positions + /// (and matching attributes) from a source mesh. Exactly one is required. + #[new] + #[pyo3(signature = (capacity=None, attributes=None, geometry=None))] + pub fn new( + capacity: Option, + attributes: Option>>, + geometry: Option<&Geometry>, + ) -> PyResult { + let attrs: Vec = attributes + .unwrap_or_default() + .iter() + .map(|a| (**a).clone()) + .collect(); + let attr_entities: Vec = attrs.iter().map(|a| a.entity).collect(); + + let entity = match (capacity, geometry) { + (Some(cap), None) => particles_create(cap, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, Some(g)) => particles_create_from_geometry(g.entity, attr_entities) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?, + (None, None) => { + return Err(PyRuntimeError::new_err( + "Particles requires either capacity or geometry", + )); + } + (Some(_), Some(_)) => { + return Err(PyRuntimeError::new_err( + "Particles accepts capacity or geometry, not both", + )); + } + }; + + Ok(Self { + entity, + name_to_attr: Particles::build_name_index(&attrs)?, + }) + } + + #[getter] + pub fn capacity(&self) -> PyResult { + particles_capacity(self.entity).map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Backing `Buffer` for a registered attribute, or `None` if not registered. + /// The element type matches the attribute's format so `read()` returns + /// typed values. + pub fn buffer(&self, attribute: &Attribute) -> PyResult> { + let buf = particles_buffer(self.entity, attribute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let (_, fmt) = geometry_attribute_info(attribute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + let element_type = match AttributeFormat::from_inner(fmt) { + AttributeFormat::Float => shader_value::ShaderValue::Float(0.0), + AttributeFormat::Float2 => shader_value::ShaderValue::Float2([0.0; 2]), + AttributeFormat::Float3 => shader_value::ShaderValue::Float3([0.0; 3]), + AttributeFormat::Float4 => shader_value::ShaderValue::Float4([0.0; 4]), + }; + Ok(buf.map(|e| Buffer::from_entity(e, Some(element_type)))) + } + + /// Dispatch a compute kernel against these particles' buffers. Buffers + /// are auto-bound by attribute name; kwargs are forwarded to + /// `compute.set(...)`. For example: + /// + /// ```python + /// p.apply(noise, scale=0.25, strength=0.02, time=t) + /// ``` + #[pyo3(signature = (compute, **kwargs))] + pub fn apply( + &self, + compute: &Compute, + kwargs: Option<&Bound<'_, PyDict>>, + ) -> PyResult<()> { + if let Some(kwargs) = kwargs { + compute.set(Some(kwargs))?; + } + particles_apply(self.entity, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Emit `n` particles into the next ring-buffer slots. Per-attribute data + /// is passed as kwargs keyed by attribute name; each value is a flat list + /// of `n * format.float_count()` floats. + /// + /// ```python + /// p.emit(50, position=[x0,y0,z0, x1,y1,z1, ...], color=[r0,g0,b0,a0, ...]) + /// ``` + #[pyo3(signature = (n, **kwargs))] + pub fn emit(&self, n: u32, kwargs: Option<&Bound<'_, PyDict>>) -> PyResult<()> { + let Some(kwargs) = kwargs else { + return particles_emit(self.entity, n, vec![]) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))); + }; + let mut data: Vec<(Entity, Vec)> = Vec::new(); + for (key, value) in kwargs.iter() { + let name: String = key.extract()?; + let (attr_entity, fmt) = self.name_to_attr.get(&name).copied().ok_or_else(|| { + PyRuntimeError::new_err(format!( + "no attribute named '{name}' (registered: {:?})", + self.name_to_attr.keys().collect::>() + )) + })?; + let floats: Vec = value.extract()?; + let expected = (n as usize) * fmt.float_count(); + if floats.len() != expected { + return Err(PyRuntimeError::new_err(format!( + "attribute '{name}': expected {expected} floats ({} per particle × {n}), got {}", + fmt.float_count(), + floats.len(), + ))); + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + data.push((attr_entity, bytes)); + } + particles_emit(self.entity, n, data) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } + + /// Emit `n` particles via a GPU kernel. Buffer bindings and a + /// `emit_range: vec4 = (base_slot, n, capacity, 0)` uniform are + /// auto-bound; set any other uniforms via `compute.set(...)` first. + pub fn emit_gpu(&self, n: u32, compute: &Compute) -> PyResult<()> { + particles_emit_gpu(self.entity, n, compute.entity) + .map_err(|e| PyRuntimeError::new_err(format!("{e}"))) + } +} + +impl Drop for Particles { + fn drop(&mut self) { + let _ = particles_destroy(self.entity); + } +} + +/// Built-in noise kernel. Uniforms: `scale`, `strength`, `time`. +pub fn kernel_noise() -> PyResult { + let entity = particles_kernel_noise().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) +} + +/// Built-in transform kernel: scale → axis-angle rotate → translate. Uniforms: +/// `translate: vec3`, `rotation_axis: vec3`, `rotation_angle: f32`, +/// `scale: vec3`. Identity defaults are seeded so unset uniforms are no-ops. +pub fn kernel_transform() -> PyResult { + let entity = particles_kernel_transform().map_err(|e| PyRuntimeError::new_err(format!("{e}")))?; + Ok(Compute::from_entity(entity)) +} diff --git a/crates/processing_render/Cargo.toml b/crates/processing_render/Cargo.toml index a2097a3..11e25b4 100644 --- a/crates/processing_render/Cargo.toml +++ b/crates/processing_render/Cargo.toml @@ -21,7 +21,6 @@ raw-window-handle = "0.6" half = "2.7" crossbeam-channel = "0.5" processing_core = { workspace = true } -processing_midi = { workspace = true } [build-dependencies] wesl = { workspace = true, features = ["package"] } diff --git a/crates/processing_render/src/compute.rs b/crates/processing_render/src/compute.rs new file mode 100644 index 0000000..1f3d78a --- /dev/null +++ b/crates/processing_render/src/compute.rs @@ -0,0 +1,400 @@ +use std::collections::BTreeSet; + +use bevy::asset::RenderAssetUsages; +use bevy::reflect::PartialReflect; +use bevy::{ + prelude::*, + render::{ + RenderApp, + render_asset::RenderAssets, + render_resource::{ + BindGroupLayoutDescriptor, Buffer as WgpuBuffer, BufferDescriptor, BufferUsages, + CachedComputePipelineId, CachedPipelineState, CommandEncoderDescriptor, + ComputePassDescriptor, ComputePipelineDescriptor, MapMode, PipelineCache, PollType, + }, + renderer::{RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + texture::GpuImage, + }, +}; + +use bevy_naga_reflect::dynamic_shader::DynamicShader; + +use crate::image::Image as PImage; +use crate::material::custom::{Shader, apply_reflect_field, shader_value_to_reflect}; +use crate::shader_value::ShaderValue; +use processing_core::error::{ProcessingError, Result}; + +pub struct ComputePlugin; + +impl Plugin for ComputePlugin { + fn build(&self, app: &mut App) { + app.add_systems(Last, invalidate_rw_buffers); + } +} + +#[derive(Component)] +pub struct Buffer { + pub handle: Handle, + pub readback_buffer: WgpuBuffer, + pub size: u64, + pub synced: bool, + pub bound_rw: bool, +} + +fn readback_buffer(device: &RenderDevice, size: u64) -> WgpuBuffer { + device.create_buffer(&BufferDescriptor { + label: Some("Buffer Readback"), + size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }) +} + +pub fn create_buffer( + In(size): In, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let handle = buffers.add(ShaderBuffer::new( + &vec![0u8; size as usize], + RenderAssetUsages::all(), + )); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + +pub fn create_buffer_with_data( + In(data): In>, + mut commands: Commands, + mut buffers: ResMut>, + render_device: Res, +) -> Entity { + let size = data.len() as u64; + let handle = buffers.add(ShaderBuffer::new(&data, RenderAssetUsages::all())); + commands + .spawn(Buffer { + handle, + readback_buffer: readback_buffer(&render_device, size), + size, + synced: true, + bound_rw: false, + }) + .id() +} + +pub fn write_buffer_cpu( + In((handle, offset, data)): In<(Handle, u64, Vec)>, + mut buffers: ResMut>, +) -> Result<()> { + let mut asset = buffers + .get_mut(&handle) + .ok_or(ProcessingError::BufferNotFound)?; + let dst = asset.data.as_mut().ok_or(ProcessingError::BufferNotFound)?; + let start = offset as usize; + let end = start + data.len(); + dst[start..end].copy_from_slice(&data); + Ok(()) +} + +/// Caller must write bytes back via `get_mut_untracked` to avoid triggering +/// a re-upload. +pub fn read_buffer_gpu( + In((handle, readback_buffer, size)): In<(Handle, WgpuBuffer, u64)>, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, +) -> Result> { + let gpu_buffer = &gpu_buffers + .get(&handle) + .ok_or(ProcessingError::BufferNotFound)? + .buffer; + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + encoder.copy_buffer_to_buffer(gpu_buffer, 0, &readback_buffer, 0, size); + render_queue.submit(std::iter::once(encoder.finish())); + + let buffer_slice = readback_buffer.slice(0..size); + let (s, r) = crossbeam_channel::bounded(1); + buffer_slice.map_async(MapMode::Read, move |result| { + let _ = s.send(result); + }); + render_device + .poll(PollType::wait_indefinitely()) + .map_err(|e| ProcessingError::BufferMapError(format!("poll failed: {e}")))?; + r.recv() + .map_err(|e| ProcessingError::BufferMapError(format!("map channel closed: {e}")))? + .map_err(|e| ProcessingError::BufferMapError(format!("map failed: {e}")))?; + + let bytes = buffer_slice.get_mapped_range().to_vec(); + readback_buffer.unmap(); + Ok(bytes) +} + +pub fn invalidate_rw_buffers(mut buffers: Query<&mut Buffer>) { + for mut buf in &mut buffers { + if buf.bound_rw && buf.synced { + buf.synced = false; + } + } +} + +pub fn destroy_buffer(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} + +#[derive(Component)] +pub struct Compute { + pub shader: DynamicShader, + pub entry_point: String, + pub pipeline_id: CachedComputePipelineId, + pub bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)>, +} + +fn queue_pipeline( + In(descriptor): In, + pipeline_cache: Res, +) -> CachedComputePipelineId { + pipeline_cache.queue_compute_pipeline(descriptor) +} + +fn pump_pipeline( + In(id): In, + mut pipeline_cache: ResMut, +) -> Result { + pipeline_cache.process_queue(); + match pipeline_cache.get_compute_pipeline_state(id) { + CachedPipelineState::Ok(_) => Ok(true), + CachedPipelineState::Err(e) => Err(ProcessingError::PipelineCompileError(format!("{e}"))), + _ => Ok(false), + } +} + +pub fn create_compute(app: &mut App, shader_entity: Entity) -> Result { + let (module, shader_handle) = { + let program = app + .world() + .get::(shader_entity) + .ok_or(ProcessingError::ShaderNotFound)?; + (program.module.clone(), program.shader_handle.clone()) + }; + + let compute_ep = module + .entry_points + .iter() + .find(|ep| ep.stage == naga::ShaderStage::Compute) + .ok_or_else(|| { + ProcessingError::ShaderCompilationError( + "Shader has no @compute entry point".to_string(), + ) + })?; + let entry_point = compute_ep.name.clone(); + + let mut shader = DynamicShader::new(module) + .map_err(|e| ProcessingError::ShaderCompilationError(e.to_string()))?; + shader.init(); + + let reflection = shader.reflection(); + let groups: BTreeSet = reflection.parameters().map(|p| p.group()).collect(); + + let bind_group_layout_descriptors: Vec<(u32, BindGroupLayoutDescriptor)> = groups + .iter() + .map(|&group| { + let entries = reflection.bind_group_layout(group); + ( + group, + BindGroupLayoutDescriptor { + label: "compute_bind_group_layout".into(), + entries, + }, + ) + }) + .collect(); + + let max_group = groups.iter().last().copied().map_or(0, |g| g + 1); + let mut layout_descriptors = vec![BindGroupLayoutDescriptor::default(); max_group as usize]; + for (group, desc) in &bind_group_layout_descriptors { + layout_descriptors[*group as usize] = desc.clone(); + } + + let descriptor = ComputePipelineDescriptor { + label: Some("processing_compute".into()), + layout: layout_descriptors, + immediate_size: 0, + shader: shader_handle.clone(), + shader_defs: Vec::new(), + entry_point: Some(entry_point.clone().into()), + zero_initialize_workgroup_memory: true, + }; + + let pipeline_id = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(queue_pipeline, descriptor) + .unwrap(); + + const MAX_WAIT: u32 = 64; + for _ in 0..MAX_WAIT { + app.update(); + let done = app + .sub_app_mut(RenderApp) + .world_mut() + .run_system_cached_with(pump_pipeline, pipeline_id) + .unwrap()?; + if done { + return Ok(app + .world_mut() + .spawn(Compute { + shader, + entry_point, + pipeline_id, + bind_group_layout_descriptors, + }) + .id()); + } + } + Err(ProcessingError::PipelineNotReady(MAX_WAIT)) +} + +pub fn set_compute_property( + In((entity, name, value)): In<(Entity, String, ShaderValue)>, + mut computes: Query<&mut Compute>, + mut p_buffers: Query<&mut Buffer>, + p_images: Query<&PImage>, +) -> Result<()> { + use bevy_naga_reflect::reflect::ParameterCategory; + + let mut compute = computes + .get_mut(entity) + .map_err(|_| ProcessingError::ComputeNotFound)?; + + // Resource values (buffers / textures) bind directly to top-level parameters + // and need a category check. Scalar / vector / matrix values may target + // either a top-level uniform or a nested struct field (e.g. `params.dt`), + // so we let `apply_reflect_field` handle the path resolution itself. + match value { + ShaderValue::Buffer(buf_entity) => { + let category = compute + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Buffer", + ))); + }; + let mut buffer = p_buffers + .get_mut(buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + compute.shader.insert(&name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; + } + Ok(()) + } + ShaderValue::Texture(img_entity) => { + let category = compute + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + if !matches!( + category, + ParameterCategory::Texture | ParameterCategory::StorageTexture + ) { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Texture", + ))); + } + let image = p_images + .get(img_entity) + .map_err(|_| ProcessingError::ImageNotFound)?; + compute.shader.insert(&name, image.handle.clone()); + Ok(()) + } + v => { + let reflect_value: Box = shader_value_to_reflect(&v)?; + apply_reflect_field(&mut compute.shader, &name, &*reflect_value) + } + } +} + +pub fn dispatch( + In((pipeline_id, layout_descriptors, shader, x, y, z)): In<( + CachedComputePipelineId, + Vec<(u32, BindGroupLayoutDescriptor)>, + DynamicShader, + u32, + u32, + u32, + )>, + pipeline_cache: Res, + render_device: Res, + render_queue: Res, + gpu_images: Res>, + gpu_buffers: Res>, +) -> Result<()> { + let pipeline = pipeline_cache + .get_compute_pipeline(pipeline_id) + .ok_or(ProcessingError::PipelineNotReady(0))? + .clone(); + + let reflection = shader.reflection(); + + let mut bind_groups = Vec::new(); + for (group, desc) in &layout_descriptors { + let layout = pipeline_cache.get_bind_group_layout(desc); + let bindings = + reflection.create_bindings(*group, &shader, &render_device, &gpu_images, &gpu_buffers); + + let bind_group_entries: Vec<_> = bindings + .iter() + .map( + |(binding, resource)| bevy::render::render_resource::BindGroupEntry { + binding: *binding, + resource: resource.get_binding(), + }, + ) + .collect(); + + let bind_group = render_device.create_bind_group( + Some("compute_bind_group"), + &layout, + &bind_group_entries, + ); + bind_groups.push(bind_group); + } + + let mut encoder = render_device.create_command_encoder(&CommandEncoderDescriptor::default()); + { + let mut pass = encoder.begin_compute_pass(&ComputePassDescriptor { + label: Some("compute_pass"), + ..Default::default() + }); + pass.set_pipeline(&pipeline); + for ((group, _), bg) in layout_descriptors.iter().zip(bind_groups.iter()) { + pass.set_bind_group(*group, bg, &[]); + } + pass.dispatch_workgroups(x, y, z); + } + render_queue.submit(std::iter::once(encoder.finish())); + + Ok(()) +} + +pub fn destroy_compute(In(entity): In, mut commands: Commands) -> Result<()> { + commands.entity(entity).despawn(); + Ok(()) +} diff --git a/crates/processing_render/src/geometry/attribute.rs b/crates/processing_render/src/geometry/attribute.rs index ecef0b2..6f8391c 100644 --- a/crates/processing_render/src/geometry/attribute.rs +++ b/crates/processing_render/src/geometry/attribute.rs @@ -148,6 +148,15 @@ impl AttributeFormat { } } + pub fn byte_size(self) -> usize { + match self { + Self::Float => 4, + Self::Float2 => 8, + Self::Float3 => 12, + Self::Float4 => 16, + } + } + pub fn from_u8(value: u8) -> Option { match value { 1 => Some(Self::Float), @@ -189,6 +198,22 @@ impl Attribute { } } + /// Like [`Self::from_builtin`] but with a friendly user-facing name. + /// `inner` is the underlying Bevy mesh attribute (used for mesh layout + /// matching); `name` is the identifier the user sees and that custom + /// shaders bind by. + pub fn from_builtin_with_name( + name: &'static str, + inner: MeshVertexAttribute, + format: AttributeFormat, + ) -> Self { + Self { + name, + format, + inner, + } + } + pub fn id(&self) -> u64 { hash_attr_name(self.name) } @@ -200,40 +225,64 @@ pub struct BuiltinAttributes { pub normal: Entity, pub color: Entity, pub uv: Entity, + /// Per-instance rotation as a quaternion `(x, y, z, w)`. Field-only. + pub rotation: Entity, + /// Per-instance scale `(x, y, z)`. Field-only. + pub scale: Entity, + /// Per-particle lifecycle flag: `0.0` = alive, non-zero = dead (skipped in + /// preprocessing). Field-only. The pack pass writes this into + /// `MeshCullingData::dead`. + pub dead: Entity, } impl FromWorld for BuiltinAttributes { fn from_world(world: &mut World) -> Self { let position = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "position", Mesh::ATTRIBUTE_POSITION, AttributeFormat::Float3, )) .id(); let normal = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "normal", Mesh::ATTRIBUTE_NORMAL, AttributeFormat::Float3, )) .id(); let color = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "color", Mesh::ATTRIBUTE_COLOR, AttributeFormat::Float4, )) .id(); let uv = world - .spawn(Attribute::from_builtin( + .spawn(Attribute::from_builtin_with_name( + "uv", Mesh::ATTRIBUTE_UV_0, AttributeFormat::Float2, )) .id(); + let rotation = world + .spawn(Attribute::new("rotation", AttributeFormat::Float4)) + .id(); + let scale = world + .spawn(Attribute::new("scale", AttributeFormat::Float3)) + .id(); + let dead = world + .spawn(Attribute::new("dead", AttributeFormat::Float)) + .id(); Self { position, normal, color, uv, + rotation, + scale, + dead, } } } diff --git a/crates/processing_render/src/geometry/mod.rs b/crates/processing_render/src/geometry/mod.rs index 4393bcd..87aaee1 100644 --- a/crates/processing_render/src/geometry/mod.rs +++ b/crates/processing_render/src/geometry/mod.rs @@ -16,7 +16,7 @@ use bevy::{ render::render_resource::PrimitiveTopology, }; -use crate::render::primitive::{box_mesh, sphere_mesh}; +use crate::render::primitive::{box_mesh, grid_mesh, sphere_mesh}; use processing_core::error::{ProcessingError, Result}; pub struct GeometryPlugin; @@ -193,6 +193,25 @@ pub fn create_sphere( commands.spawn(Geometry::new(handle, layout_entity)).id() } +pub fn create_grid( + In((nx, ny, nz, spacing)): In<(u32, u32, u32, f32)>, + mut commands: Commands, + mut meshes: ResMut>, + builtins: Res, +) -> Entity { + let handle = meshes.add(grid_mesh(nx, ny, nz, spacing)); + + let layout_entity = commands + .spawn(VertexLayout::with_attributes(vec![ + builtins.position, + builtins.color, + builtins.uv, + ])) + .id(); + + commands.spawn(Geometry::new(handle, layout_entity)).id() +} + pub fn normal(world: &mut World, entity: Entity, normal: Vec3) -> Result<()> { let mut geometry = world .get_mut::(entity) diff --git a/crates/processing_render/src/lib.rs b/crates/processing_render/src/lib.rs index 3a0e0ee..3df4222 100644 --- a/crates/processing_render/src/lib.rs +++ b/crates/processing_render/src/lib.rs @@ -2,6 +2,8 @@ pub mod camera; pub mod color; +pub mod compute; +pub mod particles; pub mod geometry; pub mod gltf; pub mod graphics; @@ -10,6 +12,7 @@ pub mod light; pub mod material; pub mod monitor; pub mod render; +pub mod shader_value; pub mod sketch; pub mod surface; pub mod time; @@ -61,6 +64,8 @@ impl Plugin for ProcessingRenderPlugin { material::ProcessingMaterialPlugin, bevy::pbr::wireframe::WireframePlugin::default(), material::custom::CustomMaterialPlugin, + compute::ComputePlugin, + particles::ParticlesPlugin, camera::OrbitCameraPlugin, bevy::camera_controller::free_camera::FreeCameraPlugin, bevy::camera_controller::pan_camera::PanCameraPlugin, @@ -73,6 +78,7 @@ impl Plugin for ProcessingRenderPlugin { flush_draw_commands, add_processing_materials, add_custom_materials, + particles::material::add_particles_materials, ) .chain() .before(AssetEventSystems), @@ -1084,6 +1090,24 @@ pub fn geometry_attribute_uv() -> Entity { app_mut(|app| Ok(app.world().resource::().uv)).unwrap() } +pub fn geometry_attribute_rotation() -> Entity { + app_mut(|app| { + Ok(app + .world() + .resource::() + .rotation) + }) + .unwrap() +} + +pub fn geometry_attribute_scale() -> Entity { + app_mut(|app| Ok(app.world().resource::().scale)).unwrap() +} + +pub fn geometry_attribute_dead() -> Entity { + app_mut(|app| Ok(app.world().resource::().dead)).unwrap() +} + pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1093,6 +1117,16 @@ pub fn geometry_attribute_destroy(entity: Entity) -> error::Result<()> { }) } +pub fn geometry_attribute_info(entity: Entity) -> error::Result<(String, AttributeFormat)> { + app_mut(|app| { + let attr = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + Ok((attr.name.to_string(), attr.format)) + }) +} + pub fn geometry_create(topology: geometry::Topology) -> error::Result { app_mut(|app| { Ok(app @@ -1370,6 +1404,18 @@ pub fn geometry_sphere(radius: f32, sectors: u32, stacks: u32) -> error::Result< }) } +/// 3D lattice of `nx * ny * nz` `PointList` vertices centered at the origin, +/// `spacing` units apart. Intended as a position source for +/// [`particles_create_from_geometry`]. +pub fn geometry_grid(nx: u32, ny: u32, nz: u32, spacing: f32) -> error::Result { + app_mut(|app| { + Ok(app + .world_mut() + .run_system_cached_with(geometry::create_grid, (nx, ny, nz, spacing)) + .unwrap()) + }) +} + pub fn poll_for_sketch_updates() -> error::Result> { app_mut(|app| { Ok(app @@ -1387,9 +1433,10 @@ pub fn shader_create(source: &str) -> error::Result { }) } -/// Load a shader from a file path. +/// Load a shader. Accepts either an asset-relative path (`"shaders/foo.wgsl"`) +/// or a URL-scheme asset path (`"embedded://crate/file.wgsl"`). pub fn shader_load(path: &str) -> error::Result { - let path = std::path::PathBuf::from(path); + let path = path.to_string(); app_mut(|app| { app.world_mut() .run_system_cached_with(material::custom::load_shader, path) @@ -1422,10 +1469,142 @@ pub fn material_create_pbr() -> error::Result { }) } +/// `material_create_pbr` with `unlit = true` set on the base StandardMaterial. +pub fn material_create_unlit() -> error::Result { + let entity = material_create_pbr()?; + material_set(entity, "unlit", shader_value::ShaderValue::Float(1.0))?; + Ok(entity) +} + +/// Set the albedo source to a constant srgba color. If the material is +/// currently buffer-backed, swaps the asset back to plain PBR while +/// preserving every other `StandardMaterial` field. +pub fn material_set_albedo_color(entity: Entity, color: [f32; 4]) -> error::Result<()> { + use bevy::pbr::ExtendedMaterial; + use crate::particles::material::ParticlesMaterial; + use crate::material::ProcessingMaterial; + use crate::render::material::UntypedMaterial; + + type DefaultMat = ExtendedMaterial; + + app_mut(|app| { + let untyped = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::MaterialNotFound)? + .0 + .clone(); + let new_color = Color::srgba(color[0], color[1], color[2], color[3]); + + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); + let mat = mats + .get_mut(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + mat.into_inner().base.base_color = new_color; + return Ok(()); + } + + let Ok(handle) = untyped.try_typed::() else { + return Err(error::ProcessingError::MaterialNotFound); + }; + let world = app.world_mut(); + let preserved = { + let mut mats = world.resource_mut::>(); + let mat = mats + .get(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + let mut base = mat.base.clone(); + base.base_color = new_color; + mats.remove(&handle); + base + }; + let new_handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: preserved, + extension: ProcessingMaterial { blend_state: None }, + }); + world + .entity_mut(entity) + .insert(UntypedMaterial(new_handle.untyped())); + Ok(()) + }) +} + +/// Set the albedo source to a per-particle color buffer (`Float4` per slot, +/// indexed by `mesh.tag`). If the material is currently plain PBR, swaps the +/// asset to a `ParticlesMaterial` while preserving every other +/// `StandardMaterial` field. `base_color` modulates the buffer color, so +/// leaving it WHITE renders the buffer color verbatim. +pub fn material_set_albedo_buffer( + entity: Entity, + color_buffer_entity: Entity, +) -> error::Result<()> { + use bevy::pbr::ExtendedMaterial; + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; + use crate::material::ProcessingMaterial; + use crate::render::material::UntypedMaterial; + + type DefaultMat = ExtendedMaterial; + + app_mut(|app| { + let buffer_handle = app + .world() + .get::(color_buffer_entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let untyped = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::MaterialNotFound)? + .0 + .clone(); + + // Already field-buffer-backed: just swap the buffer handle in place. + if let Ok(handle) = untyped.clone().try_typed::() { + let mut mats = app.world_mut().resource_mut::>(); + let mat = mats + .get_mut(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)?; + mat.into_inner().extension.colors = buffer_handle; + return Ok(()); + } + + let Ok(handle) = untyped.try_typed::() else { + return Err(error::ProcessingError::MaterialNotFound); + }; + let world = app.world_mut(); + let preserved = { + let mut mats = world.resource_mut::>(); + let base = mats + .get(&handle) + .ok_or(error::ProcessingError::MaterialNotFound)? + .base + .clone(); + mats.remove(&handle); + base + }; + let new_handle = world + .resource_mut::>() + .add(ExtendedMaterial { + base: preserved, + extension: ParticlesExtension { + colors: buffer_handle, + }, + }); + world + .entity_mut(entity) + .insert(UntypedMaterial(new_handle.untyped())); + Ok(()) + }) +} + pub fn material_set( entity: Entity, name: impl Into, - value: material::MaterialValue, + value: shader_value::ShaderValue, ) -> error::Result<()> { app_mut(|app| { app.world_mut() @@ -1642,3 +1821,472 @@ pub fn gltf_light(gltf_entity: Entity, index: usize) -> error::Result { .unwrap() }) } + +pub fn buffer_create(size: u64) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer, size) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_create_with_data(data: Vec) -> error::Result { + app_mut(|app| { + let entity = app + .world_mut() + .run_system_cached_with(compute::create_buffer_with_data, data) + .unwrap(); + app.update(); + Ok(entity) + }) +} + +pub fn buffer_size(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size) + }) +} + +pub fn buffer_write(entity: Entity, data: Vec) -> error::Result<()> { + buffer_write_range(entity, 0, data, true) +} + +pub fn buffer_write_element(entity: Entity, offset: u64, data: Vec) -> error::Result<()> { + buffer_write_range(entity, offset, data, false) +} + +fn ensure_buffer_synced(app: &mut App, entity: Entity) -> error::Result<()> { + let (handle, readback_buffer, size, synced) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + ( + buf.handle.clone(), + buf.readback_buffer.clone(), + buf.size, + buf.synced, + ) + }; + if synced { + return Ok(()); + } + let bytes = app + .sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with( + compute::read_buffer_gpu, + (handle.clone(), readback_buffer, size), + ) + .unwrap()?; + + let world = app.world_mut(); + { + let mut buffers = world.resource_mut::>(); + let asset = buffers + .get_mut_untracked(handle.id()) + .ok_or(error::ProcessingError::BufferNotFound)?; + asset.data = Some(bytes); + } + + let mut buf = world + .get_mut::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + buf.synced = true; + Ok(()) +} + +fn buffer_write_range( + entity: Entity, + offset: u64, + data: Vec, + exact_size: bool, +) -> error::Result<()> { + app_mut(|app| { + let (handle, size) = { + let buf = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)?; + (buf.handle.clone(), buf.size) + }; + let end = offset.checked_add(data.len() as u64).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if exact_size && (offset != 0 || end != size) { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer_write data length {} does not match buffer size {size}; \ + destroy and re-create to resize, or use buffer_write_element for partial writes", + data.len() + ))); + } + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer write out of bounds: offset {offset} + len {} > size {size}", + data.len() + ))); + } + ensure_buffer_synced(app, entity)?; + app.world_mut() + .run_system_cached_with(compute::write_buffer_cpu, (handle, offset, data)) + .unwrap() + }) +} + +pub fn buffer_read_element(entity: Entity, offset: u64, len: u64) -> error::Result> { + buffer_read_range(entity, offset, len) +} + +pub fn buffer_read(entity: Entity) -> error::Result> { + let size = buffer_size(entity)?; + buffer_read_range(entity, 0, size) +} + +fn buffer_read_range(entity: Entity, offset: u64, len: u64) -> error::Result> { + app_mut(|app| { + let size = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .size; + let end = offset.checked_add(len).ok_or_else(|| { + error::ProcessingError::InvalidArgument("offset + len overflow".to_string()) + })?; + if end > size { + return Err(error::ProcessingError::InvalidArgument(format!( + "buffer read out of bounds: offset {offset} + len {len} > size {size}" + ))); + } + ensure_buffer_synced(app, entity)?; + let handle = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::BufferNotFound)? + .handle + .clone(); + let buffers = app + .world() + .resource::>(); + let data = buffers + .get(&handle) + .and_then(|a| a.data.as_ref()) + .ok_or(error::ProcessingError::BufferNotFound)?; + Ok(data[offset as usize..(offset + len) as usize].to_vec()) + }) +} + +pub fn buffer_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_buffer, entity) + .unwrap() + }) +} + +pub fn compute_create(shader_entity: Entity) -> error::Result { + app_mut(|app| compute::create_compute(app, shader_entity)) +} + +pub fn compute_set( + entity: Entity, + name: impl Into, + value: shader_value::ShaderValue, +) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::set_compute_property, (entity, name.into(), value)) + .unwrap() + }) +} + +pub fn compute_dispatch(entity: Entity, x: u32, y: u32, z: u32) -> error::Result<()> { + app_mut(|app| { + app.update(); + + let args = { + let c = app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ComputeNotFound)?; + ( + c.pipeline_id, + c.bind_group_layout_descriptors.clone(), + c.shader.clone(), + x, + y, + z, + ) + }; + app.sub_app_mut(bevy::render::RenderApp) + .world_mut() + .run_system_cached_with(compute::dispatch, args) + .unwrap() + }) +} + +pub fn compute_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(compute::destroy_compute, entity) + .unwrap() + }) +} + +pub fn particles_create(capacity: u32, attribute_entities: Vec) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(particles::create, (capacity, attribute_entities)) + .unwrap() + }) +} + +/// Capacity = `geometry`'s vertex count. Builtin attributes (`position`, +/// `normal`, `color`, `uv`) are seeded from the matching mesh attribute when +/// formats line up; everything else is zero-initialized. +pub fn particles_create_from_geometry( + geometry_entity: Entity, + attribute_entities: Vec, +) -> error::Result { + app_mut(|app| { + app.world_mut() + .run_system_cached_with( + particles::create_from_geometry, + (geometry_entity, attribute_entities), + ) + .unwrap() + }) +} + +pub fn particles_destroy(entity: Entity) -> error::Result<()> { + app_mut(|app| { + app.world_mut() + .run_system_cached_with(particles::destroy, entity) + .unwrap() + }) +} + +pub fn particles_capacity(entity: Entity) -> error::Result { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .capacity) + }) +} + +pub fn particles_buffer(entity: Entity, attribute_entity: Entity) -> error::Result> { + app_mut(|app| { + Ok(app + .world() + .get::(entity) + .ok_or(error::ProcessingError::ParticlesNotFound)? + .buffer(attribute_entity)) + }) +} + +/// GPU-driven emission. Dispatches `compute_entity` over `count` invocations +/// to initialize the next `count` ring-buffer slots. Auto-binds attribute +/// buffers (same convention as [`particles_apply`]) and a `vec4` uniform +/// `emit_range = (base_slot, count, capacity, 0)` from which the kernel +/// derives its target slot. CPU-side counterpart: [`particles_emit`]. +pub fn particles_emit_gpu( + particles_entity: Entity, + count: u32, + compute_entity: Entity, +) -> error::Result<()> { + if count == 0 { + return Ok(()); + } + const WORKGROUP_SIZE: u32 = 64; + + let (capacity, head, buffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + if count > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "particles_emit_gpu count={} exceeds field capacity {}", + count, field.capacity + ))); + } + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + buffers.push((attr.name.to_string(), buf_entity)); + } + Ok((field.capacity, field.emit_head, buffers)) + })?; + + for (name, buf_entity) in buffers { + match compute_set( + compute_entity, + name, + shader_value::ShaderValue::Buffer(buf_entity), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + match compute_set( + compute_entity, + "emit_range", + shader_value::ShaderValue::Float4([head as f32, count as f32, capacity as f32, 0.0]), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + + let workgroup_count = count.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1)?; + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field.emit_head = (field.emit_head + count) % field.capacity; + Ok(()) + }) +} + +/// CPU-driven emission. Writes per-attribute byte payloads into the next `n` +/// ring-buffer slots. Each entry in `attribute_data` must be exactly +/// `attr.byte_size * n` bytes. On wrap, oldest slots are overwritten. +pub fn particles_emit( + particles_entity: Entity, + n: u32, + attribute_data: Vec<(Entity, Vec)>, +) -> error::Result<()> { + if n == 0 { + return Ok(()); + } + + let (capacity, head, attr_specs) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + if n > field.capacity { + return Err(error::ProcessingError::InvalidArgument(format!( + "particles_emit n={} exceeds field capacity {}", + n, field.capacity + ))); + } + let mut specs: Vec<(Entity, u32, Entity)> = Vec::with_capacity(attribute_data.len()); + for (attr_entity, _) in &attribute_data { + let attr = world + .get::(*attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + let buf = field.buffer(*attr_entity).ok_or_else(|| { + error::ProcessingError::InvalidArgument(format!( + "particles have no buffer for attribute {:?}", + attr_entity + )) + })?; + specs.push((*attr_entity, attr.format.byte_size() as u32, buf)); + } + Ok((field.capacity, field.emit_head, specs)) + })?; + + for ((_, bytes), &(_, byte_size, buf)) in attribute_data.iter().zip(attr_specs.iter()) { + let expected = (n as usize) * (byte_size as usize); + if bytes.len() != expected { + return Err(error::ProcessingError::InvalidArgument(format!( + "expected {} bytes ({} particles * {} bytes), got {}", + expected, + n, + byte_size, + bytes.len() + ))); + } + let first_chunk_n = (capacity - head).min(n); + let split = (first_chunk_n as usize) * (byte_size as usize); + let first_offset = (head as u64) * (byte_size as u64); + buffer_write_element(buf, first_offset, bytes[..split].to_vec())?; + if first_chunk_n < n { + buffer_write_element(buf, 0, bytes[split..].to_vec())?; + } + } + + app_mut(|app| { + let mut field = app + .world_mut() + .get_mut::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + field.emit_head = (field.emit_head + n) % field.capacity; + Ok(()) + }) +} + +/// Built-in noise kernel: displaces `position` by 3D value noise. Uniforms: +/// `scale: f32`, `strength: f32`, `time: f32`. +pub fn particles_kernel_noise() -> error::Result { + let shader = shader_load(particles::kernels::NOISE_PATH)?; + compute_create(shader) +} + +/// Built-in transform kernel: scale → axis-angle rotate → translate on +/// `position`. Uniforms: `translate: vec3`, `rotation_axis: vec3`, +/// `rotation_angle: f32`, `scale: vec3`. Identity defaults are seeded so +/// any unset parameter behaves as a no-op (without them, default-zero +/// `scale` would collapse the field to the origin on the first dispatch). +pub fn particles_kernel_transform() -> error::Result { + let shader = shader_load(particles::kernels::TRANSFORM_PATH)?; + let entity = compute_create(shader)?; + compute_set(entity, "translate", shader_value::ShaderValue::Float3([0.0; 3]))?; + compute_set(entity, "rotation_axis", shader_value::ShaderValue::Float3([0.0, 1.0, 0.0]))?; + compute_set(entity, "rotation_angle", shader_value::ShaderValue::Float(0.0))?; + compute_set(entity, "scale", shader_value::ShaderValue::Float3([1.0, 1.0, 1.0]))?; + Ok(entity) +} + +/// Dispatch `compute_entity` against the [`Particles`]'s buffers. Each buffer +/// is auto-bound by attribute name; undeclared bindings are skipped. Kernels +/// must declare `@workgroup_size(64)`. Set uniforms via `compute_set` first. +pub fn particles_apply(particles_entity: Entity, compute_entity: Entity) -> error::Result<()> { + const WORKGROUP_SIZE: u32 = 64; + + let (capacity, buffers) = app_mut(|app| { + let world = app.world(); + let field = world + .get::(particles_entity) + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let mut buffers: Vec<(String, Entity)> = Vec::with_capacity(field.buffers.len()); + for (&attr_entity, &buf_entity) in &field.buffers { + let attr = world + .get::(attr_entity) + .ok_or(error::ProcessingError::InvalidEntity)?; + buffers.push((attr.name.to_string(), buf_entity)); + } + Ok((field.capacity, buffers)) + })?; + + for (name, buf_entity) in buffers { + match compute_set( + compute_entity, + name, + shader_value::ShaderValue::Buffer(buf_entity), + ) { + Ok(()) => {} + Err(error::ProcessingError::UnknownShaderProperty(_)) => {} + Err(e) => return Err(e), + } + } + + let workgroup_count = capacity.div_ceil(WORKGROUP_SIZE); + compute_dispatch(compute_entity, workgroup_count, 1, 1) +} diff --git a/crates/processing_render/src/material/custom.rs b/crates/processing_render/src/material/custom.rs index 04080b8..23065ce 100644 --- a/crates/processing_render/src/material/custom.rs +++ b/crates/processing_render/src/material/custom.rs @@ -51,8 +51,8 @@ use bevy_naga_reflect::dynamic_shader::DynamicShader; use bevy::shader::Shader as ShaderAsset; -use crate::material::MaterialValue; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use processing_core::config::{Config, ConfigKey}; use processing_core::error::{ProcessingError, Result}; @@ -174,19 +174,27 @@ pub fn create_shader( .id()) } -pub fn load_shader(In(path): In, world: &mut World) -> Result { +pub fn load_shader(In(path): In, world: &mut World) -> Result { use bevy::asset::{ AssetPath, LoadState, handle_internal_asset_events, io::{AssetSourceId, embedded::GetAssetServer}, }; use bevy::ecs::system::RunSystemOnce; - let config = world.resource::(); - let asset_path: AssetPath = match config.get(ConfigKey::AssetRootPath) { - Some(_) => { - AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + // URL-scheme paths (e.g. `embedded://crate/file.wgsl`) parse as-is — they + // already specify their asset source. Otherwise treat as a relative path + // and fall through to the configured asset directory if any. + let asset_path: AssetPath = if path.contains("://") { + AssetPath::parse(&path).into_owned() + } else { + let config = world.resource::(); + let path = std::path::PathBuf::from(path); + match config.get(ConfigKey::AssetRootPath) { + Some(_) => { + AssetPath::from_path_buf(path).with_source(AssetSourceId::from("assets_directory")) + } + None => AssetPath::from_path_buf(path), } - None => AssetPath::from_path_buf(path), }; let handle: Handle = world.get_asset_server().load(asset_path); @@ -265,52 +273,58 @@ pub fn create_custom( Ok(commands.spawn(UntypedMaterial(handle.untyped())).id()) } -pub fn set_property( - material: &mut CustomMaterial, +pub fn set_property(material: &mut CustomMaterial, name: &str, value: &ShaderValue) -> Result<()> { + let reflect_value: Box = shader_value_to_reflect(value)?; + apply_reflect_field(&mut material.shader, name, &*reflect_value) +} + +pub(crate) fn apply_reflect_field( + shader: &mut DynamicShader, name: &str, - value: &MaterialValue, + value: &dyn PartialReflect, ) -> Result<()> { - let reflect_value: Box = material_value_to_reflect(value)?; - - if let Some(field) = material.shader.field_mut(name) { - field.apply(&*reflect_value); + if let Some(field) = shader.field_mut(name) { + field.apply(value); return Ok(()); } - let param_name = find_param_containing_field(&material.shader, name); + let param_name = find_param_containing_field(shader, name); if let Some(param_name) = param_name - && let Some(param) = material.shader.field_mut(¶m_name) + && let Some(param) = shader.field_mut(¶m_name) && let ReflectMut::Struct(s) = param.reflect_mut() && let Some(field) = s.field_mut(name) { - field.apply(&*reflect_value); + field.apply(value); return Ok(()); } - Err(ProcessingError::UnknownMaterialProperty(name.to_string())) + Err(ProcessingError::UnknownShaderProperty(name.to_string())) } -fn material_value_to_reflect(value: &MaterialValue) -> Result> { +pub(crate) fn shader_value_to_reflect(value: &ShaderValue) -> Result> { Ok(match value { - MaterialValue::Float(v) => Box::new(*v), - MaterialValue::Float2(v) => Box::new(Vec2::from_array(*v)), - MaterialValue::Float3(v) => Box::new(Vec3::from_array(*v)), - MaterialValue::Float4(v) => Box::new(Vec4::from_array(*v)), - MaterialValue::Int(v) => Box::new(*v), - MaterialValue::Int2(v) => Box::new(IVec2::from_array(*v)), - MaterialValue::Int3(v) => Box::new(IVec3::from_array(*v)), - MaterialValue::Int4(v) => Box::new(IVec4::from_array(*v)), - MaterialValue::UInt(v) => Box::new(*v), - MaterialValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), - MaterialValue::Texture(_) => { - return Err(ProcessingError::UnknownMaterialProperty( - "Texture properties not yet supported for custom materials".to_string(), + ShaderValue::Float(v) => Box::new(*v), + ShaderValue::Float2(v) => Box::new(Vec2::from_array(*v)), + ShaderValue::Float3(v) => Box::new(Vec3::from_array(*v)), + ShaderValue::Float4(v) => Box::new(Vec4::from_array(*v)), + ShaderValue::Int(v) => Box::new(*v), + ShaderValue::Int2(v) => Box::new(IVec2::from_array(*v)), + ShaderValue::Int3(v) => Box::new(IVec3::from_array(*v)), + ShaderValue::Int4(v) => Box::new(IVec4::from_array(*v)), + ShaderValue::UInt(v) => Box::new(*v), + ShaderValue::Mat4(v) => Box::new(Mat4::from_cols_array(v)), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => { + return Err(ProcessingError::InvalidArgument( + "Texture/Buffer must be bound via set_property, not as a uniform value".to_string(), )); } }) } -fn find_param_containing_field(shader: &DynamicShader, field_name: &str) -> Option { +pub(crate) fn find_param_containing_field( + shader: &DynamicShader, + field_name: &str, +) -> Option { for i in 0..shader.field_len() { if let Some(field) = shader.field_at(i) && let ReflectRef::Struct(s) = field.reflect_ref() diff --git a/crates/processing_render/src/material/mod.rs b/crates/processing_render/src/material/mod.rs index d49427e..961044a 100644 --- a/crates/processing_render/src/material/mod.rs +++ b/crates/processing_render/src/material/mod.rs @@ -1,7 +1,9 @@ pub mod custom; pub mod pbr; +use crate::compute; use crate::render::material::UntypedMaterial; +use crate::shader_value::ShaderValue; use bevy::material::descriptor::RenderPipelineDescriptor; use bevy::material::specialize::SpecializedMeshPipelineError; use bevy::mesh::MeshVertexBufferLayoutRef; @@ -11,6 +13,7 @@ use bevy::pbr::{ use bevy::prelude::*; use bevy::render::render_resource::{AsBindGroup, BlendState}; use bevy::shader::ShaderRef; +use bevy_naga_reflect::reflect::ParameterCategory; use processing_core::error::{self, ProcessingError}; pub struct ProcessingMaterialPlugin; @@ -38,21 +41,6 @@ impl Plugin for ProcessingMaterialPlugin { #[derive(Resource)] pub struct DefaultMaterial(pub Entity); -#[derive(Debug, Clone)] -pub enum MaterialValue { - Float(f32), - Float2([f32; 2]), - Float3([f32; 3]), - Float4([f32; 4]), - Int(i32), - Int2([i32; 2]), - Int3([i32; 3]), - Int4([i32; 4]), - UInt(u32), - Mat4([f32; 16]), - Texture(Entity), -} - pub fn create_pbr( mut commands: Commands, mut materials: ResMut>>, @@ -69,14 +57,16 @@ pub fn create_pbr( } pub fn set_property( - In((entity, name, value)): In<(Entity, String, MaterialValue)>, + In((entity, name, value)): In<(Entity, String, ShaderValue)>, material_handles: Query<&UntypedMaterial>, images: Query<&crate::image::Image>, mut extended_materials: ResMut>>, + mut particles_materials: ResMut>, mut custom_materials: ResMut>, + mut p_buffers: Query<&mut compute::Buffer>, ) -> error::Result<()> { let texture_handle = match &value { - MaterialValue::Texture(img_entity) => Some( + ShaderValue::Texture(img_entity) => Some( images .get(*img_entity) .map_err(|_| ProcessingError::ImageNotFound)? @@ -101,10 +91,46 @@ pub fn set_property( return pbr::set_property(&mut extended.base, &name, &value, texture_handle); } + if let Ok(handle) = untyped + .0 + .clone() + .try_typed::() + { + let mut extended = particles_materials + .get_mut(&handle) + .ok_or(ProcessingError::MaterialNotFound)?; + return pbr::set_property(&mut extended.base, &name, &value, texture_handle); + } + if let Ok(handle) = untyped.0.clone().try_typed::() { let mut mat = custom_materials .get_mut(&handle) .ok_or(ProcessingError::MaterialNotFound)?; + + if let ShaderValue::Buffer(buf_entity) = &value { + let mut buffer = p_buffers + .get_mut(*buf_entity) + .map_err(|_| ProcessingError::BufferNotFound)?; + + let category = mat + .shader + .reflection() + .parameter(&name) + .map(|p| p.category()) + .ok_or_else(|| ProcessingError::UnknownShaderProperty(name.clone()))?; + + let ParameterCategory::Storage { read_only } = category else { + return Err(ProcessingError::InvalidArgument(format!( + "property `{name}` expects {category:?}, got Buffer" + ))); + }; + mat.shader.insert(&name, buffer.handle.clone()); + if !read_only { + buffer.bound_rw = true; + } + return Ok(()); + } + return custom::set_property(&mut mat, &name, &value); } diff --git a/crates/processing_render/src/material/pbr.rs b/crates/processing_render/src/material/pbr.rs index 55613ac..4c81e5f 100644 --- a/crates/processing_render/src/material/pbr.rs +++ b/crates/processing_render/src/material/pbr.rs @@ -1,18 +1,18 @@ use bevy::prelude::*; -use super::MaterialValue; +use crate::shader_value::ShaderValue; use processing_core::error::{ProcessingError, Result}; /// Set a property on a StandardMaterial by name. pub fn set_property( material: &mut StandardMaterial, name: &str, - value: &MaterialValue, + value: &ShaderValue, texture_handle: Option>, ) -> Result<()> { match name { "base_color" | "color" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); @@ -20,7 +20,7 @@ pub fn set_property( material.base_color = Color::srgba(c[0], c[1], c[2], c[3]); } "metallic" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -28,7 +28,7 @@ pub fn set_property( material.metallic = *v; } "roughness" | "perceptual_roughness" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -36,7 +36,7 @@ pub fn set_property( material.perceptual_roughness = *v; } "reflectance" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -44,7 +44,7 @@ pub fn set_property( material.reflectance = *v; } "emissive" => { - let MaterialValue::Float4(c) = value else { + let ShaderValue::Float4(c) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float4, got {value:?}" ))); @@ -52,7 +52,7 @@ pub fn set_property( material.emissive = LinearRgba::new(c[0], c[1], c[2], c[3]); } "unlit" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -60,7 +60,7 @@ pub fn set_property( material.unlit = *v > 0.5; } "double_sided" => { - let MaterialValue::Float(v) = value else { + let ShaderValue::Float(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Float, got {value:?}" ))); @@ -68,7 +68,7 @@ pub fn set_property( material.double_sided = *v > 0.5; } "alpha_mode" => { - let MaterialValue::Int(v) = value else { + let ShaderValue::Int(v) = value else { return Err(ProcessingError::InvalidArgument(format!( "'{name}' expects Int, got {value:?}" ))); @@ -97,7 +97,7 @@ pub fn set_property( material.base_color_texture = Some(handle); } _ => { - return Err(ProcessingError::UnknownMaterialProperty(name.to_string())); + return Err(ProcessingError::UnknownShaderProperty(name.to_string())); } } Ok(()) diff --git a/crates/processing_render/src/particles/kernels/mod.rs b/crates/processing_render/src/particles/kernels/mod.rs new file mode 100644 index 0000000..ca46709 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/mod.rs @@ -0,0 +1,18 @@ +//! Built-in compute kernels for [`Particles`](super::Particles), embedded as +//! assets and dispatched via `particles_apply`. + +use bevy::asset::embedded_asset; +use bevy::prelude::*; + +pub struct ParticlesKernelsPlugin; + +impl Plugin for ParticlesKernelsPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "noise.wgsl"); + embedded_asset!(app, "transform.wgsl"); + } +} + +pub const NOISE_PATH: &str = "embedded://processing_render/particles/kernels/noise.wgsl"; +pub const TRANSFORM_PATH: &str = + "embedded://processing_render/particles/kernels/transform.wgsl"; diff --git a/crates/processing_render/src/particles/kernels/noise.wgsl b/crates/processing_render/src/particles/kernels/noise.wgsl new file mode 100644 index 0000000..72e5178 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/noise.wgsl @@ -0,0 +1,65 @@ +// Per-particle position displacement by sampled 3D value noise. + +struct Params { + scale: f32, + strength: f32, + time: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +fn hash(p: vec3) -> f32 { + let q = fract(p * 0.3183099) + vec3(0.1, 0.2, 0.3); + let r = q + dot(q, q.yzx + 19.19); + return fract(r.x * r.y * r.z); +} + +fn value_noise(p: vec3) -> f32 { + let i = floor(p); + let f = fract(p); + let u = f * f * (3.0 - 2.0 * f); + return mix( + mix( + mix(hash(i + vec3(0.0, 0.0, 0.0)), + hash(i + vec3(1.0, 0.0, 0.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 0.0)), + hash(i + vec3(1.0, 1.0, 0.0)), u.x), + u.y), + mix( + mix(hash(i + vec3(0.0, 0.0, 1.0)), + hash(i + vec3(1.0, 0.0, 1.0)), u.x), + mix(hash(i + vec3(0.0, 1.0, 1.0)), + hash(i + vec3(1.0, 1.0, 1.0)), u.x), + u.y), + u.z); +} + +fn noise3(p: vec3) -> vec3 { + return vec3( + value_noise(p), + value_noise(p + vec3(31.4, 0.0, 0.0)), + value_noise(p + vec3(0.0, 71.7, 0.0)), + ) * 2.0 - 1.0; +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + let sample = p * params.scale + vec3(params.time, params.time * 0.7, params.time * 1.3); + let n = noise3(sample); + let new_p = p + n * params.strength; + position[i * 3u + 0u] = new_p.x; + position[i * 3u + 1u] = new_p.y; + position[i * 3u + 2u] = new_p.z; +} diff --git a/crates/processing_render/src/particles/kernels/transform.wgsl b/crates/processing_render/src/particles/kernels/transform.wgsl new file mode 100644 index 0000000..2c9c766 --- /dev/null +++ b/crates/processing_render/src/particles/kernels/transform.wgsl @@ -0,0 +1,45 @@ +// Affine on each particle position: scale → axis-angle rotate → translate. + +struct Params { + translate: vec3, + rotation_angle: f32, + rotation_axis: vec3, + scale: vec3, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +fn rotate(p: vec3, axis: vec3, angle: f32) -> vec3 { + let c = cos(angle); + let s = sin(angle); + return p * c + cross(axis, p) * s + axis * dot(axis, p) * (1.0 - c); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + + var p = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + + p = p * params.scale; + + let axis_len = length(params.rotation_axis); + if axis_len > 1.0e-6 && abs(params.rotation_angle) > 1.0e-8 { + p = rotate(p, params.rotation_axis / axis_len, params.rotation_angle); + } + + p = p + params.translate; + + position[i * 3u + 0u] = p.x; + position[i * 3u + 1u] = p.y; + position[i * 3u + 2u] = p.z; +} diff --git a/crates/processing_render/src/particles/material.rs b/crates/processing_render/src/particles/material.rs new file mode 100644 index 0000000..cb349c3 --- /dev/null +++ b/crates/processing_render/src/particles/material.rs @@ -0,0 +1,56 @@ +//! Per-particle albedo on top of `StandardMaterial`. The `unlit` flag on the +//! base material toggles between lit and unlit; `apply_pbr_lighting` +//! short-circuits when set. + +use std::ops::Deref; + +use bevy::asset::embedded_asset; +use bevy::pbr::{ExtendedMaterial, MaterialExtension, MaterialPlugin}; +use bevy::prelude::*; +use bevy::render::{render_resource::AsBindGroup, storage::ShaderBuffer}; +use bevy::shader::ShaderRef; + +use crate::render::material::UntypedMaterial; + +pub struct ParticlesMaterialPlugin; + +impl Plugin for ParticlesMaterialPlugin { + fn build(&self, app: &mut App) { + embedded_asset!(app, "particles.wgsl"); + app.add_plugins(MaterialPlugin::::default()); + } +} + +pub type ParticlesMaterial = ExtendedMaterial; + +#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)] +pub struct ParticlesExtension { + #[storage(100, read_only)] + pub colors: Handle, +} + +impl MaterialExtension for ParticlesExtension { + fn fragment_shader() -> ShaderRef { + "embedded://processing_render/particles/particles.wgsl".into() + } + + fn deferred_fragment_shader() -> ShaderRef { + "embedded://processing_render/particles/particles.wgsl".into() + } +} + +/// Promote `UntypedMaterial(handle)` to `MeshMaterial3d` +/// where the handle's type matches. Sibling of `add_processing_materials`. +pub fn add_particles_materials( + mut commands: Commands, + meshes: Query<(Entity, &UntypedMaterial)>, +) { + for (entity, handle) in meshes.iter() { + let handle = handle.deref().clone(); + if let Ok(handle) = handle.try_typed::() { + commands + .entity(entity) + .insert(MeshMaterial3d::(handle)); + } + } +} diff --git a/crates/processing_render/src/particles/mod.rs b/crates/processing_render/src/particles/mod.rs new file mode 100644 index 0000000..079de01 --- /dev/null +++ b/crates/processing_render/src/particles/mod.rs @@ -0,0 +1,207 @@ +//! GPU-resident particle / instancing container. See `docs/particles.md`. + +pub mod kernels; +pub mod material; +pub mod pack; + +use bevy::asset::RenderAssetUsages; +use bevy::mesh::VertexAttributeValues; +use bevy::pbr::gpu_instance_batch::GpuInstanceBatchPlugin; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::render_resource::{BufferDescriptor, BufferUsages}; +use bevy::render::renderer::RenderDevice; +use bevy::render::storage::ShaderBuffer; + +use processing_core::error::{ProcessingError, Result}; + +use crate::compute; +use crate::geometry::{Attribute, AttributeFormat, Geometry}; + +pub struct ParticlesPlugin; + +impl Plugin for ParticlesPlugin { + fn build(&self, app: &mut App) { + app.add_plugins(GpuInstanceBatchPlugin); + app.add_plugins(pack::ParticlesPackPlugin); + app.add_plugins(material::ParticlesMaterialPlugin); + app.add_plugins(kernels::ParticlesKernelsPlugin); + } +} + +#[derive(Component)] +pub struct Particles { + pub capacity: u32, + /// `Attribute` entity → backing `compute::Buffer` entity. + pub buffers: HashMap, + /// Lazy persistent rasterization entity. Must outlive the per-frame draw + /// because `GpuInstanceBatchReservations` queue mesh batches one frame + /// behind, so respawning per-frame loses the reservation. + pub draw_entity: Option, + /// Ring-buffer write cursor for `particles_emit`. Wraps at `capacity`. + pub emit_head: u32, +} + +impl Particles { + pub fn buffer(&self, attribute: Entity) -> Option { + self.buffers.get(&attribute).copied() + } +} + +/// Render-side marker pointing at the [`Particles`] entity to pack from. +#[derive(Component, Clone, Copy)] +pub struct ParticlesDraw { + pub particles: Entity, +} + +pub fn create( + In((capacity, attribute_entities)): In<(u32, Vec)>, + mut commands: Commands, + attributes: Query<&Attribute>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let mut buffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let byte_size = capacity as u64 * attr.format.byte_size() as u64; + let buffer_entity = make_buffer( + &mut commands, + &mut shader_buffers, + &render_device, + &vec![0u8; byte_size as usize], + ); + buffers.insert(attr_entity, buffer_entity); + } + + let entity = commands + .spawn(Particles { + capacity, + buffers, + draw_entity: None, + emit_head: 0, + }) + .id(); + Ok(entity) +} + +/// Capacity = source mesh's vertex count. Registered attributes are seeded +/// from the matching mesh attribute (by name + format); unmatched ones are +/// zero-initialized. +pub fn create_from_geometry( + In((geom_entity, attribute_entities)): In<(Entity, Vec)>, + mut commands: Commands, + geometries: Query<&Geometry>, + attributes: Query<&Attribute>, + meshes: Res>, + mut shader_buffers: ResMut>, + render_device: Res, +) -> Result { + let geom = geometries + .get(geom_entity) + .map_err(|_| ProcessingError::GeometryNotFound)?; + let mesh = meshes + .get(&geom.handle) + .ok_or(ProcessingError::GeometryNotFound)?; + let capacity = mesh.count_vertices() as u32; + + let mut buffers = HashMap::with_capacity(attribute_entities.len()); + for attr_entity in attribute_entities { + let attr = attributes + .get(attr_entity) + .map_err(|_| ProcessingError::InvalidEntity)?; + let byte_size = capacity as u64 * attr.format.byte_size() as u64; + + let initial = mesh + .attribute(attr.inner) + .and_then(|values| attribute_values_to_bytes(values, attr.format)) + .filter(|bytes| bytes.len() == byte_size as usize) + .unwrap_or_else(|| vec![0u8; byte_size as usize]); + + let buffer_entity = + make_buffer(&mut commands, &mut shader_buffers, &render_device, &initial); + buffers.insert(attr_entity, buffer_entity); + } + + let entity = commands + .spawn(Particles { + capacity, + buffers, + draw_entity: None, + emit_head: 0, + }) + .id(); + Ok(entity) +} + +fn make_buffer( + commands: &mut Commands, + shader_buffers: &mut Assets, + render_device: &RenderDevice, + initial: &[u8], +) -> Entity { + let byte_size = initial.len() as u64; + let handle = shader_buffers.add(ShaderBuffer::new(initial, RenderAssetUsages::all())); + let readback = render_device.create_buffer(&BufferDescriptor { + label: Some("Particles Buffer Readback"), + size: byte_size, + usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + commands + .spawn(compute::Buffer { + handle, + readback_buffer: readback, + size: byte_size, + synced: true, + bound_rw: false, + }) + .id() +} + +fn attribute_values_to_bytes( + values: &VertexAttributeValues, + format: AttributeFormat, +) -> Option> { + match (format, values) { + (AttributeFormat::Float, VertexAttributeValues::Float32(v)) => { + Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()) + } + (AttributeFormat::Float2, VertexAttributeValues::Float32x2(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float3, VertexAttributeValues::Float32x3(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + (AttributeFormat::Float4, VertexAttributeValues::Float32x4(v)) => Some( + v.iter() + .flat_map(|p| p.iter().flat_map(|f| f.to_le_bytes())) + .collect(), + ), + _ => None, + } +} + +pub fn destroy( + In(entity): In, + mut commands: Commands, + particles: Query<&Particles>, +) -> Result<()> { + let p = particles + .get(entity) + .map_err(|_| ProcessingError::ParticlesNotFound)?; + for &buffer_entity in p.buffers.values() { + commands.entity(buffer_entity).despawn(); + } + if let Some(draw_entity) = p.draw_entity { + commands.entity(draw_entity).despawn(); + } + commands.entity(entity).despawn(); + Ok(()) +} diff --git a/crates/processing_render/src/particles/pack.rs b/crates/processing_render/src/particles/pack.rs new file mode 100644 index 0000000..c9a1f36 --- /dev/null +++ b/crates/processing_render/src/particles/pack.rs @@ -0,0 +1,413 @@ +//! Compute pass that writes [`Particles`] position/rotation/scale/dead into +//! the per-instance slots reserved by [`GpuBatchedMesh3d`]. Pipelines are +//! cached per `(HAS_ROTATION, HAS_SCALE, HAS_DEAD)` shader_def combination. + +use std::num::NonZeroU64; + +use bevy::core_pipeline::Core3d; +use bevy::pbr::{ + MeshCullingDataBuffer, MeshInputUniform, MeshUniform, early_gpu_preprocess, + gpu_instance_batch::GpuInstanceBatchReservations, +}; +use bevy::platform::collections::HashMap; +use bevy::prelude::*; +use bevy::render::{ + Extract, ExtractSchedule, Render, RenderApp, RenderSystems, + batching::gpu_preprocessing::BatchedInstanceBuffers, + render_asset::RenderAssets, + render_resource::{ + BindGroup, BindGroupEntry, BindGroupLayoutDescriptor, BindGroupLayoutEntry, BindingType, + BufferBindingType, CachedComputePipelineId, CachedPipelineState, ComputePassDescriptor, + ComputePipelineDescriptor, PipelineCache, ShaderStages, ShaderType, UniformBuffer, + }, + renderer::{RenderContext, RenderDevice, RenderQueue}, + storage::{GpuShaderBuffer, ShaderBuffer}, + sync_world::{MainEntity, MainEntityHashMap}, +}; +use bevy::shader::{Shader, ShaderDefVal}; + +use crate::compute; +use crate::geometry::BuiltinAttributes; + +use super::{Particles, ParticlesDraw}; + +const WORKGROUP_SIZE: u32 = 64; + +pub struct ParticlesPackPlugin; + +impl Plugin for ParticlesPackPlugin { + fn build(&self, app: &mut App) { + let shader = { + let mut shaders = app.world_mut().resource_mut::>(); + shaders.add(Shader::from_wgsl( + include_str!("pack.wgsl"), + "processing_render/particles/pack.wgsl", + )) + }; + app.insert_resource(ParticlesPackShader(shader.clone())); + + let Some(render_app) = app.get_sub_app_mut(RenderApp) else { + return; + }; + render_app + .insert_resource(ParticlesPackShader(shader)) + .init_resource::() + .init_resource::() + .init_resource::() + .add_systems(ExtractSchedule, extract_particles_draws) + .add_systems( + Render, + prepare_pack_bind_groups.in_set(RenderSystems::PrepareBindGroups), + ) + .add_systems(Core3d, dispatch_pack.before(early_gpu_preprocess)); + } +} + +#[derive(Resource, Clone)] +pub struct ParticlesPackShader(pub Handle); + +/// Specialization key — controls which `#ifdef`s are set when compiling the pack shader, +/// and which bindings are present in the bind-group layout. +#[derive(Hash, Eq, PartialEq, Clone, Copy, Debug)] +pub struct PackPipelineKey { + pub has_rotation: bool, + pub has_scale: bool, + pub has_dead: bool, +} + +pub struct CachedPackPipeline { + pub bind_group_layout: BindGroupLayoutDescriptor, + pub pipeline: CachedComputePipelineId, +} + +#[derive(Resource, Default)] +pub struct ParticlesPackPipelines { + pub by_key: HashMap, +} + +#[derive(Copy, Clone, Default, ShaderType)] +struct ParticlesPackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +pub struct ExtractedParticlesData { + pub key: PackPipelineKey, + pub position: Handle, + pub rotation: Option>, + pub scale: Option>, + pub dead: Option>, +} + +#[derive(Resource, Default)] +pub struct ExtractedParticlesDraws { + pub by_main: MainEntityHashMap, +} + +#[derive(Resource, Default)] +pub struct ParticlesPackBindGroups { + per_batch: MainEntityHashMap, +} + +struct PerBatchBindGroup { + bind_group: BindGroup, + pipeline: CachedComputePipelineId, + dispatch_count: u32, +} + +fn pack_layout_entries(key: PackPipelineKey) -> Vec { + let storage_rw = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: false }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let storage_r = BindingType::Buffer { + ty: BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + }; + let uniform = BindingType::Buffer { + ty: BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new(16), + }; + + let mut entries = vec![ + layout_entry(0, storage_rw), + layout_entry(1, storage_rw), + layout_entry(2, storage_r), + ]; + if key.has_rotation { + entries.push(layout_entry(3, storage_r)); + } + if key.has_scale { + entries.push(layout_entry(4, storage_r)); + } + if key.has_dead { + entries.push(layout_entry(5, storage_r)); + } + entries.push(layout_entry(6, uniform)); + entries +} + +fn layout_entry(binding: u32, ty: BindingType) -> BindGroupLayoutEntry { + BindGroupLayoutEntry { + binding, + visibility: ShaderStages::COMPUTE, + ty, + count: None, + } +} + +fn shader_defs_for(key: PackPipelineKey) -> Vec { + let mut defs = Vec::new(); + if key.has_rotation { + defs.push("HAS_ROTATION".into()); + } + if key.has_scale { + defs.push("HAS_SCALE".into()); + } + if key.has_dead { + defs.push("HAS_DEAD".into()); + } + defs +} + +fn get_or_create_pipeline( + pipelines: &mut ParticlesPackPipelines, + pipeline_cache: &PipelineCache, + shader: &Handle, + key: PackPipelineKey, +) -> CachedComputePipelineId { + if let Some(cached) = pipelines.by_key.get(&key) { + return cached.pipeline; + } + let bind_group_layout = BindGroupLayoutDescriptor::new( + format!( + "ParticlesPackBindGroupLayout(rot={},scale={},dead={})", + key.has_rotation, key.has_scale, key.has_dead + ), + &pack_layout_entries(key), + ); + let pipeline = pipeline_cache.queue_compute_pipeline(ComputePipelineDescriptor { + label: Some( + format!( + "particles_pack_pipeline(rot={},scale={},dead={})", + key.has_rotation, key.has_scale, key.has_dead + ) + .into(), + ), + layout: vec![bind_group_layout.clone()], + shader: shader.clone(), + shader_defs: shader_defs_for(key), + entry_point: Some("pack".into()), + ..default() + }); + pipelines.by_key.insert( + key, + CachedPackPipeline { + bind_group_layout, + pipeline, + }, + ); + pipelines.by_key.get(&key).unwrap().pipeline +} + +fn extract_particles_draws( + particles_draws: Extract>, + particles_q: Extract>, + buffers: Extract>, + builtins: Extract>, + mut extracted: ResMut, +) { + extracted.by_main.clear(); + for (entity, particles_draw) in particles_draws.iter() { + let Ok(p) = particles_q.get(particles_draw.particles) else { + continue; + }; + let Some(pos_entity) = p.buffer(builtins.position) else { + continue; + }; + let Ok(pos_buf) = buffers.get(pos_entity) else { + continue; + }; + let rotation = p + .buffer(builtins.rotation) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let scale = p + .buffer(builtins.scale) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + let dead = p + .buffer(builtins.dead) + .and_then(|e| buffers.get(e).ok()) + .map(|b| b.handle.clone()); + + let key = PackPipelineKey { + has_rotation: rotation.is_some(), + has_scale: scale.is_some(), + has_dead: dead.is_some(), + }; + extracted.by_main.insert( + MainEntity::from(entity), + ExtractedParticlesData { + key, + position: pos_buf.handle.clone(), + rotation, + scale, + dead, + }, + ); + } +} + +fn prepare_pack_bind_groups( + shader: Res, + mut pipelines: ResMut, + pipeline_cache: Res, + extracted: Res, + reservations: Res, + batched_instance_buffers: Res>, + culling_data_buffer: Res, + gpu_buffers: Res>, + render_device: Res, + render_queue: Res, + mut bind_groups: ResMut, +) { + bind_groups.per_batch.clear(); + + let Some(input_buffer) = batched_instance_buffers + .current_input_buffer + .buffer() + .buffer() + else { + return; + }; + let Some(culling_buffer) = culling_data_buffer.buffer() else { + return; + }; + + for (main_entity, data) in extracted.by_main.iter() { + let Some(reservation) = reservations.by_entity.get(main_entity) else { + continue; + }; + let Some(gpu_position) = gpu_buffers.get(&data.position) else { + continue; + }; + let gpu_rotation = data + .rotation + .as_ref() + .and_then(|h| gpu_buffers.get(h)); + if data.key.has_rotation && gpu_rotation.is_none() { + continue; + } + let gpu_scale = data.scale.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_scale && gpu_scale.is_none() { + continue; + } + let gpu_dead = data.dead.as_ref().and_then(|h| gpu_buffers.get(h)); + if data.key.has_dead && gpu_dead.is_none() { + continue; + } + + let pipeline_id = + get_or_create_pipeline(&mut pipelines, &pipeline_cache, &shader.0, data.key); + if !matches!( + pipeline_cache.get_compute_pipeline_state(pipeline_id), + CachedPipelineState::Ok(_) + ) { + continue; + } + let cached = pipelines.by_key.get(&data.key).unwrap(); + + let params = ParticlesPackParams { + base_input_index: reservation.input_buffer_base, + count: reservation.max_capacity, + ..default() + }; + let mut uniform = UniformBuffer::from(params); + uniform.write_buffer(&render_device, &render_queue); + + let mut entries: Vec = vec![ + BindGroupEntry { + binding: 0, + resource: input_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 1, + resource: culling_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 2, + resource: gpu_position.buffer.as_entire_binding(), + }, + ]; + if let Some(gpu_rotation) = gpu_rotation { + entries.push(BindGroupEntry { + binding: 3, + resource: gpu_rotation.buffer.as_entire_binding(), + }); + } + if let Some(gpu_scale) = gpu_scale { + entries.push(BindGroupEntry { + binding: 4, + resource: gpu_scale.buffer.as_entire_binding(), + }); + } + if let Some(gpu_dead) = gpu_dead { + entries.push(BindGroupEntry { + binding: 5, + resource: gpu_dead.buffer.as_entire_binding(), + }); + } + entries.push(BindGroupEntry { + binding: 6, + resource: uniform.binding().unwrap(), + }); + + let bind_group = render_device.create_bind_group( + Some("particles_pack_bind_group"), + &pipeline_cache.get_bind_group_layout(&cached.bind_group_layout), + &entries, + ); + + let dispatch_count = reservation.max_capacity.div_ceil(WORKGROUP_SIZE); + bind_groups.per_batch.insert( + *main_entity, + PerBatchBindGroup { + bind_group, + pipeline: pipeline_id, + dispatch_count, + }, + ); + } +} + +fn dispatch_pack( + mut render_context: RenderContext, + bind_groups: Res, + pipeline_cache: Res, +) { + if bind_groups.per_batch.is_empty() { + return; + } + + let mut pass = render_context + .command_encoder() + .begin_compute_pass(&ComputePassDescriptor { + label: Some("particles_pack"), + timestamp_writes: None, + }); + + for per_batch in bind_groups.per_batch.values() { + let Some(compute_pipeline) = pipeline_cache.get_compute_pipeline(per_batch.pipeline) else { + continue; + }; + pass.set_pipeline(compute_pipeline); + pass.set_bind_group(0, &per_batch.bind_group, &[]); + pass.dispatch_workgroups(per_batch.dispatch_count, 1, 1); + } +} diff --git a/crates/processing_render/src/particles/pack.wgsl b/crates/processing_render/src/particles/pack.wgsl new file mode 100644 index 0000000..e0c2b0c --- /dev/null +++ b/crates/processing_render/src/particles/pack.wgsl @@ -0,0 +1,121 @@ +// Packs Particles position/rotation/scale/dead buffers into the per-instance +// MeshInputUniform / MeshCullingData slots reserved by `GpuBatchedMesh3d`. +// HAS_ROTATION / HAS_SCALE / HAS_DEAD shader_defs gate the optional bindings. + +struct MeshInput { + world_from_local: mat3x4, + lightmap_uv_rect: vec2, + flags: u32, + previous_input_index: u32, + first_vertex_index: u32, + first_index_index: u32, + index_count: u32, + current_skin_index: u32, + material_and_lightmap_bind_group_slot: u32, + timestamp: u32, + tag: u32, + morph_descriptor_index: u32, +} + +struct MeshCullingData { + aabb_center: vec3, + _pad: f32, + aabb_half_extents: vec3, + dead: f32, +} + +struct PackParams { + base_input_index: u32, + count: u32, + _pad0: u32, + _pad1: u32, +} + +@group(0) @binding(0) var mesh_input_buffer: array; +@group(0) @binding(1) var mesh_culling_buffer: array; +@group(0) @binding(2) var position: array; +#ifdef HAS_ROTATION +@group(0) @binding(3) var rotation: array; +#endif +#ifdef HAS_SCALE +@group(0) @binding(4) var scale: array; +#endif +#ifdef HAS_DEAD +@group(0) @binding(5) var dead: array; +#endif +@group(0) @binding(6) var params: PackParams; + +// Convert a unit quaternion (x, y, z, w) into a 3x3 rotation matrix expressed +// as three column vectors. +fn quat_to_basis(q: vec4) -> mat3x3 { + let x = q.x; let y = q.y; let z = q.z; let w = q.w; + let xx = x * x; let yy = y * y; let zz = z * z; + let xy = x * y; let xz = x * z; let yz = y * z; + let wx = w * x; let wy = w * y; let wz = w * z; + return mat3x3( + vec3(1.0 - 2.0 * (yy + zz), 2.0 * (xy + wz), 2.0 * (xz - wy)), + vec3(2.0 * (xy - wz), 1.0 - 2.0 * (xx + zz), 2.0 * (yz + wx)), + vec3(2.0 * (xz + wy), 2.0 * (yz - wx), 1.0 - 2.0 * (xx + yy)), + ); +} + +@compute @workgroup_size(64) +fn pack(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if i >= params.count { + return; + } + let slot = params.base_input_index + i; + + let pos = vec3( + position[i * 3u + 0u], + position[i * 3u + 1u], + position[i * 3u + 2u], + ); + +#ifdef HAS_ROTATION + let q = vec4( + rotation[i * 4u + 0u], + rotation[i * 4u + 1u], + rotation[i * 4u + 2u], + rotation[i * 4u + 3u], + ); + let basis = quat_to_basis(q); +#else + let basis = mat3x3( + vec3(1.0, 0.0, 0.0), + vec3(0.0, 1.0, 0.0), + vec3(0.0, 0.0, 1.0), + ); +#endif + +#ifdef HAS_SCALE + let s = vec3( + scale[i * 3u + 0u], + scale[i * 3u + 1u], + scale[i * 3u + 2u], + ); +#else + let s = vec3(1.0, 1.0, 1.0); +#endif + + // mat3x4: 3 columns of vec4. Each column is one basis (x, y, z) row of the + // affine, with the column's `w` storing the translation component. + let c0 = basis[0] * s.x; + let c1 = basis[1] * s.y; + let c2 = basis[2] * s.z; + mesh_input_buffer[slot].world_from_local = mat3x4( + vec4(c0.x, c1.x, c2.x, pos.x), + vec4(c0.y, c1.y, c2.y, pos.y), + vec4(c0.z, c1.z, c2.z, pos.z), + ); + mesh_input_buffer[slot].tag = i; + + mesh_culling_buffer[slot].aabb_center = vec3(0.0, 0.0, 0.0); + mesh_culling_buffer[slot].aabb_half_extents = vec3(1.0, 1.0, 1.0); +#ifdef HAS_DEAD + mesh_culling_buffer[slot].dead = dead[i]; +#else + mesh_culling_buffer[slot].dead = 0.0; +#endif +} diff --git a/crates/processing_render/src/particles/particles.wgsl b/crates/processing_render/src/particles/particles.wgsl new file mode 100644 index 0000000..0c17040 --- /dev/null +++ b/crates/processing_render/src/particles/particles.wgsl @@ -0,0 +1,46 @@ +// Modulates StandardMaterial base_color by particle_colors[tag] then runs +// the standard PBR fragment. tag = per-instance slot index from pack.wgsl. + +#import bevy_pbr::{ + pbr_fragment::pbr_input_from_standard_material, + pbr_functions::alpha_discard, + mesh_functions, +} + +#ifdef PREPASS_PIPELINE +#import bevy_pbr::{ + prepass_io::{VertexOutput, FragmentOutput}, + pbr_deferred_functions::deferred_output, +} +#else +#import bevy_pbr::{ + forward_io::{VertexOutput, FragmentOutput}, + pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing}, +} +#endif + +@group(#{MATERIAL_BIND_GROUP}) @binding(100) +var particle_colors: array>; + +@fragment +fn fragment( + in: VertexOutput, + @builtin(front_facing) is_front: bool, +) -> FragmentOutput { + var pbr_input = pbr_input_from_standard_material(in, is_front); + + let tag = mesh_functions::get_tag(in.instance_index); + pbr_input.material.base_color = pbr_input.material.base_color * particle_colors[tag]; + + pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color); + +#ifdef PREPASS_PIPELINE + let out = deferred_output(in, pbr_input); +#else + var out: FragmentOutput; + out.color = apply_pbr_lighting(pbr_input); + out.color = main_pass_post_lighting_processing(pbr_input, out.color); +#endif + + return out; +} diff --git a/crates/processing_render/src/render/command.rs b/crates/processing_render/src/render/command.rs index 911f71e..4becadf 100644 --- a/crates/processing_render/src/render/command.rs +++ b/crates/processing_render/src/render/command.rs @@ -297,6 +297,9 @@ pub enum DrawCommand { BackgroundColor(Color), BackgroundImage(Entity), Fill(Color), + /// Per-instance albedo for `Particles`: a `compute::Buffer` of `Float4` + /// colors indexed by tag. Mutually exclusive with `Fill(Color)`. + FillBuffer(Entity), NoFill, StrokeColor(Color), NoStroke, @@ -447,6 +450,10 @@ pub enum DrawCommand { angle: f32, }, Geometry(Entity), + Particles { + particles: Entity, + geometry: Entity, + }, BlendMode(Option), Material(Entity), Box { diff --git a/crates/processing_render/src/render/mod.rs b/crates/processing_render/src/render/mod.rs index 85b54d3..8fdfae8 100644 --- a/crates/processing_render/src/render/mod.rs +++ b/crates/processing_render/src/render/mod.rs @@ -5,9 +5,10 @@ pub mod primitive; pub mod transform; use bevy::{ - camera::visibility::RenderLayers, + camera::{primitives::Aabb, visibility::RenderLayers}, ecs::system::SystemParam, - math::{Affine2, Affine3A, Mat4, Vec4}, + math::{Affine2, Affine3A, Mat4, Vec3A, Vec4}, + pbr::gpu_instance_batch::GpuBatchedMesh3d, prelude::*, render::render_resource::BlendState, }; @@ -23,6 +24,7 @@ use transform::TransformStack; use crate::{ Flush, + particles::{Particles, ParticlesDraw}, geometry::Geometry, gltf::GltfNodeTransform, image::Image, @@ -47,6 +49,8 @@ pub struct RenderResources<'w, 's> { meshes: ResMut<'w, Assets>, materials: ResMut<'w, Assets>, custom_materials: ResMut<'w, Assets>, + particles_materials: ResMut<'w, Assets>, + particle_buffers: Query<'w, 's, &'static crate::compute::Buffer>, } struct BatchState { @@ -74,6 +78,9 @@ impl BatchState { #[derive(Debug, Component)] pub struct RenderState { pub fill_color: Option, + /// Per-instance albedo buffer for [`Particles`] draws. Mutually exclusive + /// with `fill_color`. + pub fill_buffer: Option, pub stroke_color: Option, pub stroke_weight: f32, pub stroke_config: StrokeConfig, @@ -91,6 +98,7 @@ impl RenderState { pub fn new() -> Self { Self { fill_color: Some(Color::WHITE), + fill_buffer: None, stroke_color: Some(Color::BLACK), stroke_weight: 1.0, stroke_config: StrokeConfig::default(), @@ -112,6 +120,7 @@ impl RenderState { pub fn reset(&mut self) { self.fill_color = Some(Color::WHITE); + self.fill_buffer = None; self.stroke_color = Some(Color::BLACK); self.stroke_weight = 1.0; self.stroke_config = StrokeConfig::default(); @@ -166,6 +175,7 @@ pub fn flush_draw_commands( p_images: Query<&Image>, p_geometries: Query<(&Geometry, Option<&GltfNodeTransform>)>, p_material_handles: Query<&UntypedMaterial>, + mut p_particles: Query<&mut Particles>, ) { for (graphics_entity, mut cmd_buffer, mut state, render_layers, projection, camera_transform) in graphics.iter_mut() @@ -180,9 +190,15 @@ pub fn flush_draw_commands( match cmd { DrawCommand::Fill(color) => { state.fill_color = Some(color); + state.fill_buffer = None; + } + DrawCommand::FillBuffer(buf_entity) => { + state.fill_buffer = Some(buf_entity); + state.fill_color = None; } DrawCommand::NoFill => { state.fill_color = None; + state.fill_buffer = None; } DrawCommand::StrokeColor(color) => { state.stroke_color = Some(color); @@ -934,6 +950,85 @@ pub fn flush_draw_commands( batch.draw_index += 1; } + DrawCommand::Particles { particles, geometry } => { + let Some((geometry_data, _)) = p_geometries.get(geometry).ok() else { + warn!("Could not find Geometry for entity {:?}", geometry); + continue; + }; + let Ok(mut particles_data) = p_particles.get_mut(particles) else { + warn!("Could not find Particles for entity {:?}", particles); + continue; + }; + + let material_handle = if let Some(buf_entity) = state.fill_buffer { + match particles_fill_material(&mut res, buf_entity) { + Some(h) => h, + None => { + warn!("fill(buffer) entity {:?} not found", buf_entity); + continue; + } + } + } else { + let material_key = material_key_with_fill(&state); + match &material_key { + MaterialKey::Custom { + entity: mat_entity, + blend_state, + } => { + let Some(untyped) = p_material_handles.get(*mat_entity).ok() + else { + warn!("Could not find material for entity {:?}", mat_entity); + continue; + }; + clone_custom_material_with_blend( + &mut res.custom_materials, + &untyped.0, + *blend_state, + ) + } + _ => material_key.to_material(&mut res.materials), + } + }; + + flush_batch(&mut res, &mut batch, &p_material_handles); + + let mesh_handle = geometry_data.handle.clone(); + let capacity = particles_data.capacity; + let render_layers = batch.render_layers.clone(); + match particles_data.draw_entity { + Some(e) => { + res.commands.entity(e).insert(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + render_layers, + )); + } + None => { + let e = res + .commands + .spawn(( + GpuBatchedMesh3d { + mesh: mesh_handle, + max_capacity: capacity, + }, + UntypedMaterial(material_handle), + Aabb { + center: Vec3A::ZERO, + half_extents: Vec3A::splat(1000.0), + }, + ParticlesDraw { particles }, + render_layers, + )) + .id(); + particles_data.draw_entity = Some(e); + } + } + + batch.draw_index += 1; + } DrawCommand::BlendMode(blend_state) => { state.blend_state = blend_state; } @@ -1201,6 +1296,30 @@ fn material_key_with_color( } } +/// Allocate a fresh `ParticlesMaterial` reading albedo from `buf_entity`. +/// Not cached: bind-group + uniform upload is cheap enough at one per frame. +fn particles_fill_material( + res: &mut RenderResources, + buf_entity: Entity, +) -> Option { + use crate::particles::material::{ParticlesExtension, ParticlesMaterial}; + + let buf = res.particle_buffers.get(buf_entity).ok()?; + let handle = res.particles_materials.add(ParticlesMaterial { + base: StandardMaterial { + base_color: Color::WHITE, + perceptual_roughness: 0.4, + metallic: 0.0, + cull_mode: None, + ..Default::default() + }, + extension: ParticlesExtension { + colors: buf.handle.clone(), + }, + }); + Some(handle.untyped()) +} + fn material_key_with_fill(state: &RenderState) -> MaterialKey { let color = state.fill_color.unwrap_or(Color::WHITE); material_key_with_color(&state.material_key, color, state.blend_state) diff --git a/crates/processing_render/src/render/primitive/mod.rs b/crates/processing_render/src/render/primitive/mod.rs index 8a3649f..0695a63 100644 --- a/crates/processing_render/src/render/primitive/mod.rs +++ b/crates/processing_render/src/render/primitive/mod.rs @@ -30,8 +30,8 @@ pub use shape::{ build_polygon_stroke, }; pub use shape3d::{ - box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, plane_mesh, - sphere_mesh, tetrahedron_mesh, torus_mesh, + box_mesh, capsule_mesh, cone_mesh, conical_frustum_mesh, cylinder_mesh, grid_mesh, + plane_mesh, sphere_mesh, tetrahedron_mesh, torus_mesh, }; pub use triangle::triangle; diff --git a/crates/processing_render/src/render/primitive/shape3d.rs b/crates/processing_render/src/render/primitive/shape3d.rs index ac059a7..1f93da2 100644 --- a/crates/processing_render/src/render/primitive/shape3d.rs +++ b/crates/processing_render/src/render/primitive/shape3d.rs @@ -1,3 +1,5 @@ +use bevy::asset::RenderAssetUsages; +use bevy::mesh::PrimitiveTopology; use bevy::prelude::*; use bevy::render::mesh::VertexAttributeValues; @@ -89,6 +91,41 @@ pub fn tetrahedron_mesh(radius: f32) -> Mesh { mesh } +/// 3D lattice of `nx * ny * nz` points centered at the origin, with `spacing` +/// units between adjacent points along each axis. Topology is `PointList`; +/// the mesh is meant primarily as a position source for `field_create_from_geometry`, +/// not for rasterization. UVs are normalized lattice coordinates `(x/(nx-1), y/(ny-1))`. +pub fn grid_mesh(nx: u32, ny: u32, nz: u32, spacing: f32) -> Mesh { + let count = (nx as usize) * (ny as usize) * (nz as usize); + let mut positions = Vec::with_capacity(count); + let mut uvs = Vec::with_capacity(count); + + let half_x = (nx as f32 - 1.0) * 0.5 * spacing; + let half_y = (ny as f32 - 1.0) * 0.5 * spacing; + let half_z = (nz as f32 - 1.0) * 0.5 * spacing; + let inv_x = if nx > 1 { 1.0 / (nx as f32 - 1.0) } else { 0.0 }; + let inv_y = if ny > 1 { 1.0 / (ny as f32 - 1.0) } else { 0.0 }; + + for ix in 0..nx { + for iy in 0..ny { + for iz in 0..nz { + positions.push([ + ix as f32 * spacing - half_x, + iy as f32 * spacing - half_y, + iz as f32 * spacing - half_z, + ]); + uvs.push([ix as f32 * inv_x, iy as f32 * inv_y]); + } + } + } + + let mut mesh = Mesh::new(PrimitiveTopology::PointList, RenderAssetUsages::all()); + mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions); + mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs); + ensure_vertex_colors(&mut mesh); + mesh +} + pub fn plane_mesh(width: f32, height: f32) -> Mesh { let plane = bevy::math::primitives::Plane3d::default(); let mut mesh = plane.mesh().size(width, height).build(); diff --git a/crates/processing_render/src/shader_value.rs b/crates/processing_render/src/shader_value.rs new file mode 100644 index 0000000..9d2c77d --- /dev/null +++ b/crates/processing_render/src/shader_value.rs @@ -0,0 +1,82 @@ +use bevy::prelude::*; + +#[derive(Debug, Clone)] +pub enum ShaderValue { + Float(f32), + Float2([f32; 2]), + Float3([f32; 3]), + Float4([f32; 4]), + Int(i32), + Int2([i32; 2]), + Int3([i32; 3]), + Int4([i32; 4]), + UInt(u32), + Mat4([f32; 16]), + Texture(Entity), + Buffer(Entity), +} + +impl ShaderValue { + pub fn to_bytes(&self) -> Option> { + match self { + ShaderValue::Float(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Float2(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Float3(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Float4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Int(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Int2(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::Int3(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::Int4(v) => Some(v.iter().flat_map(|i| i.to_le_bytes()).collect()), + ShaderValue::UInt(v) => Some(v.to_le_bytes().to_vec()), + ShaderValue::Mat4(v) => Some(v.iter().flat_map(|f| f.to_le_bytes()).collect()), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } + + pub fn byte_size(&self) -> Option { + match self { + ShaderValue::Float(_) | ShaderValue::Int(_) | ShaderValue::UInt(_) => Some(4), + ShaderValue::Float2(_) | ShaderValue::Int2(_) => Some(8), + ShaderValue::Float3(_) | ShaderValue::Int3(_) => Some(12), + ShaderValue::Float4(_) | ShaderValue::Int4(_) => Some(16), + ShaderValue::Mat4(_) => Some(64), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } + + pub fn read_from_bytes(&self, bytes: &[u8]) -> Option { + fn f32s(bytes: &[u8]) -> Option<[f32; N]> { + let mut arr = [0f32; N]; + for i in 0..N { + arr[i] = f32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().ok()?); + } + Some(arr) + } + fn i32s(bytes: &[u8]) -> Option<[i32; N]> { + let mut arr = [0i32; N]; + for i in 0..N { + arr[i] = i32::from_le_bytes(bytes[i * 4..(i + 1) * 4].try_into().ok()?); + } + Some(arr) + } + match self { + ShaderValue::Float(_) => Some(ShaderValue::Float(f32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Float2(_) => Some(ShaderValue::Float2(f32s::<2>(bytes)?)), + ShaderValue::Float3(_) => Some(ShaderValue::Float3(f32s::<3>(bytes)?)), + ShaderValue::Float4(_) => Some(ShaderValue::Float4(f32s::<4>(bytes)?)), + ShaderValue::Int(_) => Some(ShaderValue::Int(i32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Int2(_) => Some(ShaderValue::Int2(i32s::<2>(bytes)?)), + ShaderValue::Int3(_) => Some(ShaderValue::Int3(i32s::<3>(bytes)?)), + ShaderValue::Int4(_) => Some(ShaderValue::Int4(i32s::<4>(bytes)?)), + ShaderValue::UInt(_) => Some(ShaderValue::UInt(u32::from_le_bytes( + bytes[..4].try_into().ok()?, + ))), + ShaderValue::Mat4(_) => Some(ShaderValue::Mat4(f32s::<16>(bytes)?)), + ShaderValue::Texture(_) | ShaderValue::Buffer(_) => None, + } + } +} diff --git a/crates/processing_wasm/Cargo.toml b/crates/processing_wasm/Cargo.toml index c43799d..12f1ba5 100644 --- a/crates/processing_wasm/Cargo.toml +++ b/crates/processing_wasm/Cargo.toml @@ -27,7 +27,4 @@ features = [ ] [dependencies.bevy] -git = "https://github.com/bevyengine/bevy" -branch = "main" -default-features = false -features = ["bevy_render", "bevy_color", "webgpu", "web"] +workspace = true diff --git a/crates/processing_wasm/src/lib.rs b/crates/processing_wasm/src/lib.rs index 120a9fe..0372ce4 100644 --- a/crates/processing_wasm/src/lib.rs +++ b/crates/processing_wasm/src/lib.rs @@ -744,7 +744,7 @@ pub fn js_material_set_float(mat_id: u64, name: &str, value: f32) -> Result<(), check(material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float(value), + shader_value::ShaderValue::Float(value), )) } @@ -760,7 +760,7 @@ pub fn js_material_set_float4( check(material_set( Entity::from_bits(mat_id), name, - material::MaterialValue::Float4([r, g, b, a]), + shader_value::ShaderValue::Float4([r, g, b, a]), )) } diff --git a/docs/particles.md b/docs/particles.md new file mode 100644 index 0000000..322f213 --- /dev/null +++ b/docs/particles.md @@ -0,0 +1,171 @@ +# Particles + +A `Particles` is a GPU-resident container of named attribute buffers, drawn by +instancing a geometry once per element. The libprocessing analogue of a Houdini +point cloud. + +## Pieces + +- **`compute::Buffer`** (`crates/processing_render/src/compute.rs`) — typed GPU + storage with CPU-side write, GPU readback, and a Python wrapper that tracks + element type. Backs every Particles attribute buffer. +- **`Attribute`** (`crates/processing_render/src/geometry/attribute.rs`) — + named typed attribute identity (`AttributeFormat::{Float, Float2, Float3, + Float4}`), shared between Geometries and Particles. Builtins: `position`, + `normal`, `color`, `uv`, plus the particles-only `rotation` (Float4 quat), + `scale` (Float3), `dead` (Float, 0=alive). +- **Upstream `processing/bevy`** commit `ee443e51` adds `GpuBatchedMesh3d` and + `GpuInstanceBatchReservations` — a fixed-capacity batch where a compute pass + writes per-instance transforms into the upstream input buffer before + `early_gpu_preprocess` consumes them. + +## Construction + +Empty: + +```rust +let velocity = geometry_attribute_create("velocity", AttributeFormat::Float3)?; +let p = particles_create(10_000, vec![geometry_attribute_position(), velocity])?; +``` + +One zero-initialized buffer per requested attribute, sized +`capacity * attr.format.byte_size()`. + +Mesh-seeded: + +```rust +let source = geometry_sphere(5.0, 32, 24)?; +let p = particles_create_from_geometry( + source, + vec![position_attr, uv_attr, color_attr], +)?; +``` + +Capacity = mesh vertex count. Builtins seed from the matching mesh attribute +(`position` ← `ATTRIBUTE_POSITION`, `normal` ← `ATTRIBUTE_NORMAL`, `color` ← +`ATTRIBUTE_COLOR`, `uv` ← `ATTRIBUTE_UV_0`); particles-only builtins and custom +attributes start at zero. + +## Apply + +```rust +let spin = compute_create(shader_create(SPIN_WGSL)?)?; +compute_set(spin, "dt", ShaderValue::Float(0.016))?; +particles_apply(p, spin)?; +``` + +`particles_apply` binds each attribute buffer by name; bindings the shader +doesn't declare are skipped. Workgroup size is fixed at 64. + +Built-in kernels: `particles_kernel_noise()` (uniforms `scale`, `strength`, +`time`), `particles_kernel_transform()` (`translate`, `rotation_axis`, +`rotation_angle`, `scale`, with identity defaults seeded so unset uniforms are +no-ops). + +## Pack pass + +Bridges Particles attribute buffers into the per-instance slots reserved by +`GpuBatchedMesh3d`. Runs as render-schedule systems: + +- `extract_particles_draws` (ExtractSchedule) — copies Particles + buffer + handles into the render world keyed by `ParticlesDraw` markers. +- `prepare_pack_bind_groups` (RenderSystems::PrepareBindGroups) — looks up or + builds the pack pipeline for the specialization key + bind group. +- `dispatch_pack` (Core3d, before `early_gpu_preprocess`) — dispatches. + +The pack shader (`particles/pack.wgsl`) is specialized per +`(HAS_ROTATION, HAS_SCALE, HAS_DEAD)`. For each slot it writes: + +- `mesh_input_buffer[base+i].world_from_local` — `mat3x4` from rotation × scale + + position translation. +- `mesh_input_buffer[base+i].tag = i` — slot index, available via + `mesh_functions::get_tag(instance_index)`. +- `MeshCullingData[base+i].dead` — from the `dead` buffer if present, else 0. + +## Materials + +`ParticlesMaterial = ExtendedMaterial` +binds a `colors: Handle` and reads `particle_colors[mesh.tag]`. +Lit vs unlit is the `unlit` flag on the base `StandardMaterial`; +`apply_pbr_lighting` short-circuits when set. + +Immediate-mode: + +```rust +graphics_record_command(g, DrawCommand::FillBuffer(color_buffer_entity))?; +graphics_record_command(g, DrawCommand::Particles { particles, geometry: shape })?; +``` + +`fill(buffer)` sets the ambient albedo source; the next +`DrawCommand::Particles` allocates a `ParticlesMaterial` carrying that buffer. + +Explicit: + +```rust +let mat = material_create_pbr()?; +material_set_albedo_buffer(mat, color_buffer_entity)?; +material_set(mat, "roughness", ShaderValue::Float(0.4))?; +``` + +`material_set_albedo_buffer` / `material_set_albedo_color` swap the backing +asset between plain PBR and `ParticlesMaterial` while preserving every other +`StandardMaterial` field. + +Custom shaders (per-particle UV, per-particle scalars, anything beyond color) +require a `CustomMaterial` that reads `mesh.tag` and indexes its own buffer. + +## Emit + +CPU-driven: + +```rust +particles_emit( + p, + n, + vec![ + (position_attr, position_bytes), // n * 12 bytes + (color_attr, color_bytes), // n * 16 bytes + (dead_attr, vec![0u8; n * 4]), // alive + ], +)?; +``` + +Writes to `[head, head+n) mod capacity` and advances `emit_head`. Two writes +when wrapping. No GPU allocator, no compaction. Capacity is a visible contract: +`>= peak_emission_rate × longest_lifespan`. + +GPU-driven: + +```rust +particles_emit_gpu(p, n, spawn_kernel)?; +``` + +Auto-binds attribute buffers and `emit_range: vec4 = (base_slot, n, +capacity, 0)`. The kernel derives its target slot from `emit_range`. + +No auto-defaults — if the field has a `dead` attribute, the caller must +include it (typically `n` zero-floats) or new slots inherit the previous +occupant's death. + +## Lifecycle + +`dead` is a builtin Float attribute (0=alive, non-zero=dead). When registered, +the pack pass writes it into `MeshCullingData::dead`; non-zero slots are +skipped in preprocessing. + +Aging is user-managed via an apply kernel that increments age and flips +`dead` when age exceeds ttl. See `particles_lifecycle.rs`. Seed `dead = 1.0` +for unemitted ring slots so they don't render before being filled. + +## Examples + +- `particles_basic` — sphere-mesh-seeded particle cloud, PBR per-particle color. +- `particles_animated` — 10×10×10 grid rotating around Y via custom apply. +- `particles_oriented` — per-particle quaternion + scale. +- `particles_colored` / `particles_colored_pbr` — explicit material setup. +- `particles_emit` — continuous CPU ring-buffer emission. +- `particles_emit_gpu` — fountain spawned by a compute kernel. +- `particles_lifecycle` — emit + age + shrink-on-death. +- `particles_from_mesh` — sphere mesh as position source. +- `particles_noise` — built-in noise kernel jittering positions. +- `particles_stress` — 1M cubes on a grid, R/G/B lights, transform spin. diff --git a/examples/compute_readback.rs b/examples/compute_readback.rs new file mode 100644 index 0000000..f470274 --- /dev/null +++ b/examples/compute_readback.rs @@ -0,0 +1,90 @@ +use processing::prelude::*; + +fn main() { + match run() { + Ok(_) => { + eprintln!("Compute readback test passed!"); + exit(0).unwrap(); + } + Err(e) => { + eprintln!("Compute readback error: {:?}", e); + exit(1).unwrap(); + } + } +} + +fn run() -> error::Result<()> { + init(Config::default())?; + + let surface = surface_create_offscreen(1, 1, 1.0, TextureFormat::Rgba8Unorm)?; + let _graphics = graphics_create(surface, 1, 1, TextureFormat::Rgba8Unorm)?; + + let buf = buffer_create(16)?; + + let shader_src = r#" +@group(0) @binding(0) +var output: array; + +@compute @workgroup_size(1) +fn main() { + output[0] = 1u; + output[1] = 2u; + output[2] = 3u; + output[3] = 4u; +} +"#; + let shader = shader_create(shader_src)?; + let compute = compute_create(shader)?; + compute_set(compute, "output", shader_value::ShaderValue::Buffer(buf))?; + + compute_dispatch(compute, 1, 1, 1)?; + + let data = buffer_read(buf)?; + let values: Vec = data + .chunks_exact(4) + .map(|c| u32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + + assert_eq!(values, vec![1, 2, 3, 4], "Compute readback mismatch!"); + eprintln!("PASS"); + + let double_src = r#" +@group(0) @binding(0) +var data: array; + +@compute @workgroup_size(4) +fn main(@builtin(global_invocation_id) id: vec3) { + data[id.x] = data[id.x] * 2.0; +} +"#; + let buf2_data: Vec = [1.0f32, 2.0, 3.0, 4.0] + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect(); + let buf2 = buffer_create_with_data(buf2_data)?; + let shader2 = shader_create(double_src)?; + let compute2 = compute_create(shader2)?; + compute_set(compute2, "data", shader_value::ShaderValue::Buffer(buf2))?; + compute_dispatch(compute2, 1, 1, 1)?; + + let data2 = buffer_read(buf2)?; + let floats: Vec = data2 + .chunks_exact(4) + .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]])) + .collect(); + assert_eq!( + floats, + vec![2.0, 4.0, 6.0, 8.0], + "In-place double mismatch!" + ); + eprintln!("PASS"); + + compute_destroy(compute)?; + compute_destroy(compute2)?; + shader_destroy(shader)?; + shader_destroy(shader2)?; + buffer_destroy(buf)?; + buffer_destroy(buf2)?; + + Ok(()) +} diff --git a/examples/custom_material.rs b/examples/custom_material.rs index b29feed..aea1b34 100644 --- a/examples/custom_material.rs +++ b/examples/custom_material.rs @@ -36,7 +36,7 @@ fn sketch() -> error::Result<()> { material_set( mat, "color", - material::MaterialValue::Float4([1.0, 0.2, 0.4, 1.0]), + shader_value::ShaderValue::Float4([1.0, 0.2, 0.4, 1.0]), )?; let mut angle = 0.0; diff --git a/examples/gltf_load.rs b/examples/gltf_load.rs index b261539..8d86958 100644 --- a/examples/gltf_load.rs +++ b/examples/gltf_load.rs @@ -2,8 +2,8 @@ use processing_glfw::GlfwContext; use bevy::math::Vec3; use processing::prelude::*; -use processing_render::material::MaterialValue; use processing_render::render::command::DrawCommand; +use processing_render::shader_value::ShaderValue; fn main() { match sketch() { @@ -50,11 +50,7 @@ fn sketch() -> error::Result<()> { let r = (t * 8.0).sin() * 0.5 + 0.5; let g = (t * 8.0 + 2.0).sin() * 0.5 + 0.5; let b = (t * 8.0 + 4.0).sin() * 0.5 + 0.5; - material_set( - duck_mat, - "base_color", - MaterialValue::Float4([r, g, b, 1.0]), - )?; + material_set(duck_mat, "base_color", ShaderValue::Float4([r, g, b, 1.0]))?; graphics_begin_draw(graphics)?; diff --git a/examples/lights.rs b/examples/lights.rs index b2d0f21..b111591 100644 --- a/examples/lights.rs +++ b/examples/lights.rs @@ -27,7 +27,7 @@ fn sketch() -> error::Result<()> { let graphics = graphics_create(surface, width, height, TextureFormat::Rgba16Float)?; let box_geo = geometry_box(100.0, 100.0, 100.0)?; let pbr_mat = material_create_pbr()?; - material_set(pbr_mat, "roughness", material::MaterialValue::Float(0.0))?; + material_set(pbr_mat, "roughness", shader_value::ShaderValue::Float(0.0))?; // We will only declare lights in `setup` // rather than calling some sort of `light()` method inside of `draw` diff --git a/examples/materials.rs b/examples/materials.rs index 5ec92d8..306ac6f 100644 --- a/examples/materials.rs +++ b/examples/materials.rs @@ -51,8 +51,12 @@ fn sketch() -> error::Result<()> { let roughness = col as f32 / (cols - 1) as f32; let metallic = row as f32 / (rows - 1) as f32; - material_set(mat, "roughness", material::MaterialValue::Float(roughness))?; - material_set(mat, "metallic", material::MaterialValue::Float(metallic))?; + material_set( + mat, + "roughness", + shader_value::ShaderValue::Float(roughness), + )?; + material_set(mat, "metallic", shader_value::ShaderValue::Float(metallic))?; materials.push(mat); } } diff --git a/examples/particles_animated.rs b/examples/particles_animated.rs new file mode 100644 index 0000000..efcc3f7 --- /dev/null +++ b/examples/particles_animated.rs @@ -0,0 +1,99 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const SPIN_SHADER: &str = r#" +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { + return; + } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 8.0, 25.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + let capacity: u32 = 1000; + let mut floats: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + floats.push((x as f32 - 4.5) * 1.0); + floats.push((y as f32 - 4.5) * 1.0); + floats.push((z as f32 - 4.5) * 1.0); + } + } + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + + let position_attr = geometry_attribute_position(); + let p = particles_create(capacity, vec![position_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write(position_buf, bytes)?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + + compute_set(spin, "dt", shader_value::ShaderValue::Float(0.01))?; + particles_apply(p, spin)?; + } + + Ok(()) +} diff --git a/examples/particles_basic.rs b/examples/particles_basic.rs new file mode 100644 index 0000000..104c85e --- /dev/null +++ b/examples/particles_basic.rs @@ -0,0 +1,70 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 0.0, 25.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + // 10x10x10 grid of positions in a 9-unit cube centered at the origin. + let capacity: u32 = 1000; + let mut floats: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + floats.push((x as f32 - 4.5) * 1.0); + floats.push((y as f32 - 4.5) * 1.0); + floats.push((z as f32 - 4.5) * 1.0); + } + } + } + let bytes: Vec = floats.iter().flat_map(|f| f.to_le_bytes()).collect(); + + let position_attr = geometry_attribute_position(); + let p = particles_create(capacity, vec![position_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write(position_buf, bytes)?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/particles_colored.rs b/examples/particles_colored.rs new file mode 100644 index 0000000..fc691ed --- /dev/null +++ b/examples/particles_colored.rs @@ -0,0 +1,76 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.25, 12, 8)?; + + // 10x10x10 grid with per-particle position + color (RGB gradient by index). + let capacity: u32 = 1000; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + for x in 0..10 { + for y in 0..10 { + for z in 0..10 { + positions.push((x as f32 - 4.5) * 1.0); + positions.push((y as f32 - 4.5) * 1.0); + positions.push((z as f32 - 4.5) * 1.0); + colors.push(x as f32 / 9.0); + colors.push(y as f32 / 9.0); + colors.push(z as f32 / 9.0); + colors.push(1.0); + } + } + } + + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/particles_colored_pbr.rs b/examples/particles_colored_pbr.rs new file mode 100644 index 0000000..e5cc5fb --- /dev/null +++ b/examples/particles_colored_pbr.rs @@ -0,0 +1,79 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + let sphere = geometry_sphere(0.3, 16, 12)?; + + // 8x8x8 grid with per-particle color (RGB gradient by index). + let capacity: u32 = 512; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + for x in 0..8 { + for y in 0..8 { + for z in 0..8 { + positions.push((x as f32 - 3.5) * 1.4); + positions.push((y as f32 - 3.5) * 1.4); + positions.push((z as f32 - 3.5) * 1.4); + colors.push(x as f32 / 7.0); + colors.push(y as f32 / 7.0); + colors.push(z as f32 / 7.0); + colors.push(1.0); + } + } + } + + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} diff --git a/examples/particles_emit.rs b/examples/particles_emit.rs new file mode 100644 index 0000000..ca27d5e --- /dev/null +++ b/examples/particles_emit.rs @@ -0,0 +1,108 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 14.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.08, 8, 6)?; + + let capacity: u32 = 2000; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let p = particles_create(capacity, vec![position_attr, color_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + + // Push unemitted slots far off-screen so they don't all render at the + // origin while the ring buffer is still filling. + let init_positions: Vec = (0..capacity * 3).map(|_| 1.0e6).collect(); + buffer_write( + position_buf, + init_positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; + + let mut frame: u32 = 0; + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + + // Emit 4 particles per frame in an outward-spiraling ring; once the ring + // buffer fills (~500 frames at 4/frame for capacity 2000), oldest get + // overwritten and the swirl continues without bound. + let burst = 4u32; + let mut positions: Vec = Vec::with_capacity(burst as usize * 3); + let mut colors: Vec = Vec::with_capacity(burst as usize * 4); + for k in 0..burst { + let i = frame * burst + k; + let t = i as f32 * 0.05; + let radius = 1.5 + (t * 0.02).min(3.0); + let height = ((t * 0.1).sin()) * 2.0; + positions.push(t.cos() * radius); + positions.push(height); + positions.push(t.sin() * radius); + // Hue sweep based on emission index. + let h = (i as f32 * 0.012) % 1.0; + let (r, g, b) = hsv_to_rgb(h, 0.85, 1.0); + colors.push(r); + colors.push(g); + colors.push(b); + colors.push(1.0); + } + + let position_bytes: Vec = positions.iter().flat_map(|f| f.to_le_bytes()).collect(); + let color_bytes: Vec = colors.iter().flat_map(|f| f.to_le_bytes()).collect(); + particles_emit( + p, + burst, + vec![(position_attr, position_bytes), (color_attr, color_bytes)], + )?; + frame += 1; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32) % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/particles_emit_gpu.rs b/examples/particles_emit_gpu.rs new file mode 100644 index 0000000..873d578 --- /dev/null +++ b/examples/particles_emit_gpu.rs @@ -0,0 +1,220 @@ +use processing_glfw::GlfwContext; +use std::time::Instant; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::geometry::AttributeFormat; +use processing_render::render::command::DrawCommand; + +const SPAWN_SHADER: &str = r#" +struct Spawn { + pos: vec4, + speed: vec4, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var color: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var age: array; +@group(0) @binding(5) var dead: array; +@group(0) @binding(6) var spawn: Spawn; +@group(0) @binding(7) var emit_range: vec4; + +fn hash(n: u32) -> u32 { + var x = n; + x = (x ^ 61u) ^ (x >> 16u); + x = x + (x << 3u); + x = x ^ (x >> 4u); + x = x * 0x27d4eb2du; + x = x ^ (x >> 15u); + return x; +} + +fn hash_unit(n: u32) -> f32 { + return f32(hash(n)) / f32(0xffffffffu); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let local_i = gid.x; + if local_i >= u32(emit_range.y) { return; } + let base = u32(emit_range.x); + let cap = u32(emit_range.z); + let slot = (base + local_i) % cap; + + let seed = base + local_i; + + // Random unit-disc direction with some upward bias. + let theta = hash_unit(seed) * 6.2831853; + let r = sqrt(hash_unit(seed * 2u + 1u)); + let dirxz = vec2(cos(theta), sin(theta)) * r; + let dy = 0.7 + 0.3 * hash_unit(seed * 3u + 7u); + let v = vec3(dirxz.x, dy, dirxz.y) * spawn.speed.x; + + position[slot * 3u + 0u] = spawn.pos.x; + position[slot * 3u + 1u] = spawn.pos.y; + position[slot * 3u + 2u] = spawn.pos.z; + + velocity[slot * 3u + 0u] = v.x; + velocity[slot * 3u + 1u] = v.y; + velocity[slot * 3u + 2u] = v.z; + + let h = fract(hash_unit(seed * 5u + 11u)); + color[slot * 4u + 0u] = 0.5 + 0.5 * sin(h * 6.28); + color[slot * 4u + 1u] = 0.5 + 0.5 * sin(h * 6.28 + 2.094); + color[slot * 4u + 2u] = 0.5 + 0.5 * sin(h * 6.28 + 4.189); + color[slot * 4u + 3u] = 1.0; + + scale[slot * 3u + 0u] = 1.0; + scale[slot * 3u + 1u] = 1.0; + scale[slot * 3u + 2u] = 1.0; + + age[slot] = 0.0; + dead[slot] = 0.0; +} +"#; + +const MOTION_SHADER: &str = r#" +struct Params { + dt: f32, + ttl: f32, + gravity: f32, + _pad: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var velocity: array; +@group(0) @binding(2) var scale: array; +@group(0) @binding(3) var age: array; +@group(0) @binding(4) var dead: array; +@group(0) @binding(5) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { return; } + if dead[i] != 0.0 { return; } + + age[i] = age[i] + params.dt; + + velocity[i * 3u + 1u] = velocity[i * 3u + 1u] - params.gravity * params.dt; + + position[i * 3u + 0u] = position[i * 3u + 0u] + velocity[i * 3u + 0u] * params.dt; + position[i * 3u + 1u] = position[i * 3u + 1u] + velocity[i * 3u + 1u] * params.dt; + position[i * 3u + 2u] = position[i * 3u + 2u] + velocity[i * 3u + 2u] * params.dt; + + let life = clamp(1.0 - age[i] / params.ttl, 0.0, 1.0); + let s = life * life; + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > params.ttl { dead[i] = 1.0; } +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 16.0))?; + transform_look_at(graphics, Vec3::new(0.0, 2.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 800.0)?; + + let particle = geometry_sphere(0.12, 8, 6)?; + + let capacity: u32 = 40000; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let scale_attr = geometry_attribute_scale(); + let dead_attr = geometry_attribute_dead(); + let velocity_attr = geometry_attribute_create("velocity", AttributeFormat::Float3)?; + let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; + + let p = particles_create( + capacity, + vec![ + position_attr, + color_attr, + scale_attr, + dead_attr, + velocity_attr, + age_attr, + ], + )?; + + // Mark all unemitted slots dead so they don't render at origin. + let dead_buf = particles_buffer(p, dead_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let init_dead: Vec = (0..capacity) + .flat_map(|_| 1.0_f32.to_le_bytes()) + .collect(); + buffer_write(dead_buf, init_dead)?; + + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; + + let spawn_shader = shader_create(SPAWN_SHADER)?; + let spawn = compute_create(spawn_shader)?; + + let motion_shader = shader_create(MOTION_SHADER)?; + let motion = compute_create(motion_shader)?; + + let burst: u32 = 120; + let dt: f32 = 1.0 / 60.0; + let ttl: f32 = 2.5; + let gravity: f32 = 9.8; + let speed: f32 = 5.0; + let start = Instant::now(); + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: particle }, + )?; + graphics_end_draw(graphics)?; + + // Animate spawn point in a small circle so the fountain meanders. + let t = start.elapsed().as_secs_f32(); + let sx = t.cos() * 0.4; + let sz = t.sin() * 0.4; + compute_set( + spawn, + "pos", + shader_value::ShaderValue::Float4([sx, 7.0, sz, 0.0]), + )?; + compute_set( + spawn, + "speed", + shader_value::ShaderValue::Float4([speed, 0.0, 0.0, 0.0]), + )?; + particles_emit_gpu(p, burst, spawn)?; + + compute_set(motion, "dt", shader_value::ShaderValue::Float(dt))?; + compute_set(motion, "ttl", shader_value::ShaderValue::Float(ttl))?; + compute_set(motion, "gravity", shader_value::ShaderValue::Float(gravity))?; + particles_apply(p, motion)?; + } + + Ok(()) +} diff --git a/examples/particles_from_mesh.rs b/examples/particles_from_mesh.rs new file mode 100644 index 0000000..eefe29f --- /dev/null +++ b/examples/particles_from_mesh.rs @@ -0,0 +1,90 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + // Source mesh whose vertices become the particle positions. UVs come along + // for free and we'll use them to paint each particle a unique color. + let source = geometry_sphere(5.0, 32, 24)?; + + let position_attr = geometry_attribute_position(); + let uv_attr = geometry_attribute_uv(); + let color_attr = geometry_attribute_color(); + + // Position + uv come straight from the source sphere; color is allocated + // empty and we fill it from uv values. + let p = particles_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + let uv_buf = + particles_buffer(p, uv_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = + particles_buffer(p, color_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; + + // Read uvs back, build per-particle colors from them, write to color buffer. + let uv_bytes = buffer_read(uv_buf)?; + let mut colors: Vec = Vec::with_capacity(uv_bytes.len() * 2); + for chunk in uv_bytes.chunks_exact(8) { + let u = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let v = f32::from_le_bytes([chunk[4], chunk[5], chunk[6], chunk[7]]); + let (r, g, b) = hsv_to_rgb(u, 0.85, 1.0); + for f in [r, g, b, 1.0] { + colors.extend_from_slice(&f.to_le_bytes()); + } + let _ = v; + } + buffer_write(color_buf, colors)?; + + let particle = geometry_sphere(0.18, 10, 8)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: particle }, + )?; + graphics_end_draw(graphics)?; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32).rem_euclid(6) { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/particles_lifecycle.rs b/examples/particles_lifecycle.rs new file mode 100644 index 0000000..2969338 --- /dev/null +++ b/examples/particles_lifecycle.rs @@ -0,0 +1,188 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::geometry::AttributeFormat; +use processing_render::render::command::DrawCommand; + +const AGING_SHADER: &str = r#" +@group(0) @binding(0) var age: array; +@group(0) @binding(1) var dead: array; +@group(0) @binding(2) var position: array; +@group(0) @binding(3) var scale: array; +@group(0) @binding(4) var params: vec4; // x = dt, y = ttl + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&age); + if i >= count { + return; + } + let dt = params.x; + let ttl = params.y; + + if dead[i] != 0.0 { + return; + } + + age[i] = age[i] + dt; + // gravity-ish drop + position[i * 3u + 1u] = position[i * 3u + 1u] - dt * 1.5; + + // Shrink toward zero as age approaches ttl so dying is visible. + let life = clamp(1.0 - age[i] / ttl, 0.0, 1.0); + let s = life * life; // ease out + scale[i * 3u + 0u] = s; + scale[i * 3u + 1u] = s; + scale[i * 3u + 2u] = s; + + if age[i] > ttl { + dead[i] = 1.0; + } +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 2.0, 14.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let sphere = geometry_sphere(0.1, 8, 6)?; + + let capacity: u32 = 800; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let scale_attr = geometry_attribute_scale(); + let dead_attr = geometry_attribute_dead(); + let age_attr = geometry_attribute_create("age", AttributeFormat::Float)?; + + let p = particles_create( + capacity, + vec![ + position_attr, + color_attr, + scale_attr, + dead_attr, + age_attr, + ], + )?; + let dead_buf = particles_buffer(p, dead_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + + // Mark all slots dead initially so the unemitted ring slots don't render. + let init_dead: Vec = (0..capacity) + .flat_map(|_| 1.0_f32.to_le_bytes()) + .collect(); + buffer_write(dead_buf, init_dead)?; + + let mat = { let m = material_create_unlit()?; material_set_albedo_buffer(m, color_buf)?; m }; + let aging_shader = shader_create(AGING_SHADER)?; + let aging = compute_create(aging_shader)?; + + // burst × (ttl × 60) ≈ steady-state alive count (~360 here, well under capacity 800). + let burst: u32 = 6; + let dt: f32 = 1.0 / 60.0; + let ttl: f32 = 1.0; + let mut frame: u32 = 0; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: sphere }, + )?; + graphics_end_draw(graphics)?; + + // Spawn `burst` new particles per frame in a small fountain. + let mut positions: Vec = Vec::with_capacity(burst as usize * 3); + let mut colors: Vec = Vec::with_capacity(burst as usize * 4); + for k in 0..burst { + let i = frame * burst + k; + // Cheap pseudo-random offset. + let u = ((i.wrapping_mul(2654435761) >> 8) & 0xFFFF) as f32 / 65535.0; + let v = ((i.wrapping_mul(40503) >> 8) & 0xFFFF) as f32 / 65535.0; + let theta = u * std::f32::consts::TAU; + let r = v * 0.6; + positions.push(theta.cos() * r); + positions.push(2.5); + positions.push(theta.sin() * r); + let h = (i as f32 * 0.013) % 1.0; + let (cr, cg, cb) = hsv_to_rgb(h, 0.85, 1.0); + colors.push(cr); + colors.push(cg); + colors.push(cb); + colors.push(1.0); + } + let position_bytes: Vec = positions.iter().flat_map(|f| f.to_le_bytes()).collect(); + let color_bytes: Vec = colors.iter().flat_map(|f| f.to_le_bytes()).collect(); + let zero_floats: Vec = (0..burst).flat_map(|_| 0.0_f32.to_le_bytes()).collect(); + // Reset scale to 1 for newly emitted particles (the aging shader will + // shrink them as age progresses). + let one_scale: Vec = (0..burst) + .flat_map(|_| { + [1.0_f32, 1.0, 1.0] + .iter() + .flat_map(|f| f.to_le_bytes()) + .collect::>() + }) + .collect(); + particles_emit( + p, + burst, + vec![ + (position_attr, position_bytes), + (color_attr, color_bytes), + (scale_attr, one_scale), + (age_attr, zero_floats.clone()), + (dead_attr, zero_floats), + ], + )?; + + // Age + drop + kill. + compute_set( + aging, + "params", + shader_value::ShaderValue::Float4([dt, ttl, 0.0, 0.0]), + )?; + particles_apply(p, aging)?; + + frame += 1; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32) % 6 { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/particles_noise.rs b/examples/particles_noise.rs new file mode 100644 index 0000000..218a86a --- /dev/null +++ b/examples/particles_noise.rs @@ -0,0 +1,94 @@ +use processing_glfw::GlfwContext; +use std::time::Instant; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 4.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.95, 0.9, 0.85), 200.0)?; + + // Seed positions from a sphere mesh; noise will jitter them around their + // initial sphere shape over time. + let source = geometry_sphere(5.0, 32, 24)?; + let position_attr = geometry_attribute_position(); + let uv_attr = geometry_attribute_uv(); + let color_attr = geometry_attribute_color(); + let p = particles_create_from_geometry(source, vec![position_attr, uv_attr, color_attr])?; + + let uv_buf = + particles_buffer(p, uv_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = + particles_buffer(p, color_attr)?.ok_or(error::ProcessingError::ParticlesNotFound)?; + + // Color each particle by hue from its U coord. + let uv_bytes = buffer_read(uv_buf)?; + let mut colors: Vec = Vec::with_capacity(uv_bytes.len() * 2); + for chunk in uv_bytes.chunks_exact(8) { + let u = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + let (r, g, b) = hsv_to_rgb(u, 0.85, 1.0); + for f in [r, g, b, 1.0] { + colors.extend_from_slice(&f.to_le_bytes()); + } + } + buffer_write(color_buf, colors)?; + + let particle = geometry_sphere(0.18, 10, 8)?; + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; + let noise = particles_kernel_noise()?; + + let start = Instant::now(); + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: particle }, + )?; + graphics_end_draw(graphics)?; + + let t = start.elapsed().as_secs_f32(); + compute_set(noise, "scale", shader_value::ShaderValue::Float(0.25))?; + compute_set(noise, "strength", shader_value::ShaderValue::Float(0.02))?; + compute_set(noise, "time", shader_value::ShaderValue::Float(t * 0.5))?; + particles_apply(p, noise)?; + } + + Ok(()) +} + +fn hsv_to_rgb(h: f32, s: f32, v: f32) -> (f32, f32, f32) { + let i = (h * 6.0).floor(); + let f = h * 6.0 - i; + let p = v * (1.0 - s); + let q = v * (1.0 - f * s); + let t = v * (1.0 - (1.0 - f) * s); + match (i as i32).rem_euclid(6) { + 0 => (v, t, p), + 1 => (q, v, p), + 2 => (p, v, t), + 3 => (p, q, v), + 4 => (t, p, v), + _ => (v, p, q), + } +} diff --git a/examples/particles_oriented.rs b/examples/particles_oriented.rs new file mode 100644 index 0000000..c283d33 --- /dev/null +++ b/examples/particles_oriented.rs @@ -0,0 +1,142 @@ +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const SPIN_SHADER: &str = r#" +@group(0) @binding(0) var rotation: array; +@group(0) @binding(1) var params: vec4; // x = dt + +fn quat_mul(a: vec4, b: vec4) -> vec4 { + return vec4( + a.w * b.xyz + b.w * a.xyz + cross(a.xyz, b.xyz), + a.w * b.w - dot(a.xyz, b.xyz), + ); +} + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&rotation) / 4u; + if i >= count { + return; + } + let dt = params.x; + let q = vec4( + rotation[i * 4u + 0u], + rotation[i * 4u + 1u], + rotation[i * 4u + 2u], + rotation[i * 4u + 3u], + ); + let half_angle = dt * 0.5; + let dq = vec4(0.0, sin(half_angle), 0.0, cos(half_angle)); + let q_new = quat_mul(q, dq); + rotation[i * 4u + 0u] = q_new.x; + rotation[i * 4u + 1u] = q_new.y; + rotation[i * 4u + 2u] = q_new.z; + rotation[i * 4u + 3u] = q_new.w; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + transform_set_position(graphics, Vec3::new(0.0, 6.0, 18.0))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + + let _light = + light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; + + let cube = geometry_box(0.6, 0.6, 0.6)?; + + let capacity: u32 = 125; + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut rotations: Vec = Vec::with_capacity(capacity as usize * 4); + let mut scales: Vec = Vec::with_capacity(capacity as usize * 3); + for x in 0..5 { + for y in 0..5 { + for z in 0..5 { + positions.push((x as f32 - 2.0) * 1.6); + positions.push((y as f32 - 2.0) * 1.6); + positions.push((z as f32 - 2.0) * 1.6); + // identity quat + rotations.push(0.0); + rotations.push(0.0); + rotations.push(0.0); + rotations.push(1.0); + // scale varies per position + let s = 0.5 + ((x + y + z) as f32 * 0.06); + scales.push(s); + scales.push(s); + scales.push(s); + } + } + } + + let position_attr = geometry_attribute_position(); + let rotation_attr = geometry_attribute_rotation(); + let scale_attr = geometry_attribute_scale(); + let p = particles_create(capacity, vec![position_attr, rotation_attr, scale_attr])?; + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let rotation_buf = particles_buffer(p, rotation_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let scale_buf = particles_buffer(p, scale_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + rotation_buf, + rotations.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + scale_buf, + scales.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let pbr = material_create_pbr()?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.4))?; + + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.06, 0.06, 0.08)), + )?; + graphics_record_command( + graphics, + DrawCommand::Fill(bevy::color::Color::srgb(0.9, 0.5, 0.3)), + )?; + graphics_record_command(graphics, DrawCommand::Material(pbr))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: cube }, + )?; + graphics_end_draw(graphics)?; + + compute_set( + spin, + "params", + shader_value::ShaderValue::Float4([0.015, 0.0, 0.0, 0.0]), + )?; + particles_apply(p, spin)?; + } + + Ok(()) +} diff --git a/examples/particles_stress.rs b/examples/particles_stress.rs new file mode 100644 index 0000000..4331dcf --- /dev/null +++ b/examples/particles_stress.rs @@ -0,0 +1,142 @@ +//! Stress test: a "silly" number of PBR-lit cubes slowly rotating. Mostly here +//! to feel out the practical upper bound — change `GRID` to push it harder. + +use processing_glfw::GlfwContext; + +use bevy::math::Vec3; +use processing::prelude::*; +use processing_render::render::command::DrawCommand; + +const GRID: u32 = 100; // GRID^3 = 1,000,000 particles +const SPACING: f32 = 1.0; + +const SPIN_SHADER: &str = r#" +struct Params { + dt: f32, +} + +@group(0) @binding(0) var position: array; +@group(0) @binding(1) var params: Params; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + let count = arrayLength(&position) / 3u; + if i >= count { return; } + let cs = cos(params.dt); + let sn = sin(params.dt); + let x = position[i * 3u + 0u]; + let z = position[i * 3u + 2u]; + position[i * 3u + 0u] = x * cs - z * sn; + position[i * 3u + 2u] = x * sn + z * cs; +} +"#; + +fn main() { + sketch().unwrap(); + exit(0).unwrap(); +} + +fn hash_u32(mut x: u32) -> u32 { + x = (x ^ 61).wrapping_add(x >> 16); + x = x.wrapping_add(x << 3); + x ^= x >> 4; + x = x.wrapping_mul(0x27d4eb2d); + x ^= x >> 15; + x +} + +fn hash_unit(seed: u32) -> f32 { + (hash_u32(seed) as f32) / (u32::MAX as f32) +} + +fn sketch() -> error::Result<()> { + let mut glfw_ctx = GlfwContext::new(900, 700)?; + init(Config::default())?; + + let surface = glfw_ctx.create_surface(900, 700)?; + let graphics = graphics_create(surface, 900, 700, TextureFormat::Rgba16Float)?; + + graphics_mode_3d(graphics)?; + let extent = (GRID as f32) * SPACING * 0.5; + transform_set_position(graphics, Vec3::new(0.0, extent * 0.6, extent * 2.5))?; + transform_look_at(graphics, Vec3::new(0.0, 0.0, 0.0))?; + graphics_orbit_camera(graphics)?; + + // Three directional R/G/B lights from cardinal axes — each cube face picks + // up the closest light's color so the lighting variation is obvious. + let red = light_create_directional(graphics, bevy::color::Color::srgb(1.0, 0.0, 0.0), 1000.0)?; + transform_set_position(red, Vec3::new(1.0, 0.0, 0.0))?; + transform_look_at(red, Vec3::ZERO)?; + + let green = + light_create_directional(graphics, bevy::color::Color::srgb(0.0, 1.0, 0.0), 1000.0)?; + transform_set_position(green, Vec3::new(0.0, 1.0, 0.0))?; + transform_look_at(green, Vec3::ZERO)?; + + let blue = light_create_directional(graphics, bevy::color::Color::srgb(0.0, 0.0, 1.0), 1000.0)?; + transform_set_position(blue, Vec3::new(0.0, 0.0, 1.0))?; + transform_look_at(blue, Vec3::ZERO)?; + + let cube = geometry_box(0.35, 0.35, 0.35)?; + + let capacity = GRID * GRID * GRID; + let position_attr = geometry_attribute_position(); + let color_attr = geometry_attribute_color(); + let p = particles_create(capacity, vec![position_attr, color_attr])?; + + let mut positions: Vec = Vec::with_capacity(capacity as usize * 3); + let mut colors: Vec = Vec::with_capacity(capacity as usize * 4); + let extent_half = (GRID as f32) * SPACING * 0.5; + for i in 0..capacity { + // Three independent hash streams give us pseudo-random uniform values. + let rx = hash_unit(i.wrapping_mul(2654435761).wrapping_add(0x9E37)); + let ry = hash_unit(i.wrapping_mul(40503).wrapping_add(0x68E1)); + let rz = hash_unit(i.wrapping_mul(2246822519).wrapping_add(0xC2B2)); + positions.push((rx * 2.0 - 1.0) * extent_half); + positions.push((ry * 2.0 - 1.0) * extent_half); + positions.push((rz * 2.0 - 1.0) * extent_half); + // Color from the same random samples — stable per particle. + colors.push(rx); + colors.push(ry); + colors.push(rz); + colors.push(1.0); + } + let position_buf = particles_buffer(p, position_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + let color_buf = particles_buffer(p, color_attr)? + .ok_or(error::ProcessingError::ParticlesNotFound)?; + buffer_write( + position_buf, + positions.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + buffer_write( + color_buf, + colors.iter().flat_map(|f| f.to_le_bytes()).collect(), + )?; + + let mat = { let m = material_create_pbr()?; material_set_albedo_buffer(m, color_buf)?; m }; + let spin_shader = shader_create(SPIN_SHADER)?; + let spin = compute_create(spin_shader)?; + + eprintln!("field_stress: {capacity} particles"); + + while glfw_ctx.poll_events() { + graphics_begin_draw(graphics)?; + graphics_record_command( + graphics, + DrawCommand::BackgroundColor(bevy::color::Color::srgb(0.04, 0.04, 0.07)), + )?; + graphics_record_command(graphics, DrawCommand::Material(mat))?; + graphics_record_command( + graphics, + DrawCommand::Particles { particles: p, geometry: cube }, + )?; + graphics_end_draw(graphics)?; + + compute_set(spin, "dt", shader_value::ShaderValue::Float(0.003))?; + particles_apply(p, spin)?; + } + + Ok(()) +} diff --git a/examples/primitives_3d.rs b/examples/primitives_3d.rs index adadc6e..1de2bbc 100644 --- a/examples/primitives_3d.rs +++ b/examples/primitives_3d.rs @@ -24,7 +24,7 @@ fn sketch() -> error::Result<()> { light_create_directional(graphics, bevy::color::Color::srgb(0.9, 0.85, 0.8), 300.0)?; let pbr = material_create_pbr()?; - material_set(pbr, "roughness", material::MaterialValue::Float(0.35))?; + material_set(pbr, "roughness", shader_value::ShaderValue::Float(0.35))?; let mut t: f32 = 0.0;