From d6554411641b9ca8a56f2842208fcef4942c174b Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Tue, 17 Mar 2026 02:03:11 +0530 Subject: [PATCH 01/18] Add vendor-agnostic wgpu compute backend Signed-off-by: Naitik Pahwa --- dave_interfaces/CMakeLists.txt | 2 - .../multibeam_sonar/CMakeLists.txt | 191 ++- .../multibeam_sonar/MultibeamSonarSensor.cc | 279 +++- .../multibeam_sonar/MultibeamSonarSensor.hh | 18 +- .../multibeam_sonar/package.xml | 3 +- .../multibeam_sonar/sonar_compute_backend.hh | 83 ++ .../multibeam_sonar/sonar_compute_cpu.cc | 179 +++ .../multibeam_sonar/sonar_compute_wgpu.cc | 214 +++ .../multibeam_sonar/sonar_compute_wgpu.hh | 28 + .../multibeam_sonar_demo/CMakeLists.txt | 28 +- .../launch/multibeam_sonar_demo.launch.py | 11 +- .../multibeam_sonar_demo/package.xml | 2 + .../multibeam_sonar_demo/scripts/plotdata.py | 34 +- .../multibeam_sonar_system/CMakeLists.txt | 78 +- .../multibeam_sonar_system/package.xml | 2 + .../wgpu_vendor/CMakeLists.txt | 55 + .../wgpu_vendor/package.xml | 12 + .../wgpu_vendor/sonar_wgpu_rust/Cargo.lock | 1156 +++++++++++++++++ .../wgpu_vendor/sonar_wgpu_rust/Cargo.toml | 17 + .../wgpu_vendor/sonar_wgpu_rust/src/fft.rs | 268 ++++ .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 581 +++++++++ .../sonar_wgpu_rust/src/pipeline.rs | 424 ++++++ .../src/shaders/backscatter.wgsl | 179 +++ .../sonar_wgpu_rust/src/shaders/convert.wgsl | 15 + .../sonar_wgpu_rust/src/shaders/fft.wgsl | 86 ++ .../sonar_wgpu_rust/src/shaders/matmul.wgsl | 71 + .../description/blueview_p900/model.sdf | 2 + 27 files changed, 3770 insertions(+), 248 deletions(-) create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/backscatter.wgsl create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl diff --git a/dave_interfaces/CMakeLists.txt b/dave_interfaces/CMakeLists.txt index 36eda427..56276f13 100644 --- a/dave_interfaces/CMakeLists.txt +++ b/dave_interfaces/CMakeLists.txt @@ -11,8 +11,6 @@ find_package(rclcpp REQUIRED) find_package(std_msgs REQUIRED) find_package(geometry_msgs REQUIRED) find_package(rosidl_default_generators REQUIRED) -find_package(gz-cmake3 REQUIRED) -find_package(gz-msgs10 REQUIRED) rosidl_generate_interfaces(${PROJECT_NAME} "msg/UsblCommand.msg" diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index c5a1d2d0..c02f0708 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -11,113 +11,104 @@ cmake_policy(SET CMP0144 NEW) project(multibeam_sonar) -set(CUDA_ARCHITECTURE "60" CACHE STRING "Target CUDA SM version") - find_package(ament_cmake REQUIRED) -find_package(CUDAToolkit QUIET) - -if(CUDAToolkit_FOUND) - - enable_language(CUDA) - find_package(CUDA REQUIRED) - message(STATUS "CUDA found, enabling CUDA support.") - include_directories(${CUDA_INCLUDE_DIRS}) - set(CUDA_NVCC_FLAGS "${CUDA_NVCC_FLAGS} -arch=sm_${CUDA_ARCHITECTURE}") - find_package(gz-cmake3 REQUIRED) - find_package(gz-sim8 REQUIRED) - find_package(gz-sensors8 REQUIRED) - find_package(gz-rendering8 REQUIRED OPTIONAL_COMPONENTS ogre ogre2) - find_package(gz-transport13 REQUIRED) - find_package(rclcpp REQUIRED) - find_package(sensor_msgs REQUIRED) - find_package(geometry_msgs REQUIRED) - find_package(rosidl_default_generators REQUIRED) - find_package(OpenCV REQUIRED) - find_package(marine_acoustic_msgs REQUIRED) - find_package(cv_bridge REQUIRED) - - set(GZ_MSGS_VER ${gz-msgs10_VERSION_MAJOR}) - set(GZ_RENDERING_VER ${gz-rendering8_VERSION_MAJOR}) - set(GZ_SENSORS_VER ${gz-sensors8_VERSION_MAJOR}) - set(GZ_TRANSPORT_VER ${gz-transport13_VERSION_MAJOR}) - set(GZ_SIM_VER ${gz-sim8_VERSION_MAJOR}) - - if(TARGET gz-rendering${GZ_RENDERING_VER}::ogre) - set(HAVE_OGRE TRUE) - set(GZ_RENDERING_TARGET gz-rendering${GZ_RENDERING_VER}-ogre) - add_definitions(-DWITH_OGRE) - endif() +find_package(gz-cmake REQUIRED) +find_package(gz-sim REQUIRED) +find_package(gz-sensors REQUIRED) +find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) +find_package(gz-transport REQUIRED) +find_package(gz-msgs REQUIRED) +find_package(rclcpp REQUIRED) +find_package(sensor_msgs REQUIRED) +find_package(geometry_msgs REQUIRED) +find_package(OpenCV REQUIRED) +find_package(marine_acoustic_msgs REQUIRED) +find_package(cv_bridge REQUIRED) +find_package(wgpu_vendor REQUIRED) + +if(TARGET gz-rendering::ogre) + add_definitions(-DWITH_OGRE) +endif() +if(TARGET gz-rendering::ogre2) + add_definitions(-DWITH_OGRE2) +endif() - if(TARGET gz-rendering${GZ_RENDERING_VER}::ogre2) - set(HAVE_OGRE2 TRUE) - set(GZ_RENDERING_TARGET gz-rendering${GZ_RENDERING_VER}-ogre2) - add_definitions(-DWITH_OGRE2) - endif() +add_library(${PROJECT_NAME} SHARED + MultibeamSonarSensor.cc + sonar_compute_cpu.cc + sonar_compute_wgpu.cc +) + +target_include_directories(${PROJECT_NAME} PUBLIC + ${gz-sensors_INCLUDE_DIRS} + ${gz-rendering_INCLUDE_DIRS} + ${gz-msgs_INCLUDE_DIRS} + ${gz-transport_INCLUDE_DIRS} + ${gz-sim_INCLUDE_DIRS} + ${rclcpp_INCLUDE_DIRS} + ${sensor_msgs_INCLUDE_DIRS} + ${geometry_msgs_INCLUDE_DIRS} + ${OpenCV_INCLUDE_DIRS} + ${marine_acoustic_msgs_INCLUDE_DIRS} + ${cv_bridge_INCLUDE_DIRS} +) + +if(DEFINED ENV{ROS_DISTRO} AND EXISTS "/opt/ros/$ENV{ROS_DISTRO}/include/cv_bridge") + target_include_directories(${PROJECT_NAME} PUBLIC "/opt/ros/$ENV{ROS_DISTRO}/include/cv_bridge") +elseif(EXISTS "/opt/ros/rolling/include/cv_bridge") + target_include_directories(${PROJECT_NAME} PUBLIC "/opt/ros/rolling/include/cv_bridge") +endif() + +target_link_libraries(${PROJECT_NAME} + gz-sim::gz-sim + gz-sensors::gz-sensors + gz-msgs::gz-msgs + gz-transport::gz-transport + gz-rendering::gz-rendering + ${rclcpp_LIBRARIES} + ${sensor_msgs_LIBRARIES} + ${geometry_msgs_LIBRARIES} + ${OpenCV_LIBS} + ${marine_acoustic_msgs_LIBRARIES} +) + +if(TARGET wgpu::wgpu_vendor) + target_link_libraries(${PROJECT_NAME} wgpu::wgpu_vendor) +elseif(TARGET wgpu_vendor) + target_link_libraries(${PROJECT_NAME} wgpu_vendor) +endif() - add_library(${PROJECT_NAME} - SHARED - MultibeamSonarSensor.cc - sonar_calculation_cuda.cu - ) - - set_target_properties(${PROJECT_NAME} - PROPERTIES - CUDA_SEPARABLE_COMPILATION ON - ) - - target_include_directories(${PROJECT_NAME} - PUBLIC - ${gz-sensors${GZ_SENSORS_VER}_INCLUDE_DIRS} - ${gz-rendering${GZ_RENDERING_VER}_INCLUDE_DIRS} - ${gz-msgs${GZ_MSGS_VER}_INCLUDE_DIRS} - ${CUDA_INCLUDE_DIRS} - ${gz-transport${GZ_TRANSPORT_VER}_INCLUDE_DIRS} - ${gz-sim${GZ_SIM_VER}_INCLUDE_DIRS} - ) - - find_library(CUBLAS_LIB cublas - HINTS - ${CUDAToolkit_LIBRARY_DIR} - /usr/local/cuda/lib64 - /usr/lib/x86_64-linux-gnu - /usr/lib - ) - if(NOT CUBLAS_LIB) - message(FATAL_ERROR "cuBLAS not found") +# Ensure the Rust static archive is linked into this shared library. +# The exported interface target may not always propagate non-installed imported +# targets across package boundaries in overlay builds. +if(DEFINED wgpu_vendor_DIR) + get_filename_component(_wgpu_vendor_prefix "${wgpu_vendor_DIR}/../../.." ABSOLUTE) + set(_sonar_wgpu_static "${_wgpu_vendor_prefix}/lib/libsonar_wgpu.a") + if(EXISTS "${_sonar_wgpu_static}") + target_link_libraries(${PROJECT_NAME} "${_sonar_wgpu_static}") endif() +endif() - target_link_libraries(${PROJECT_NAME} - ${CUDA_LIBRARIES} - ${CUDA_CUFFT_LIBRARIES} - ${CUBLAS_LIB} - ) - - install(TARGETS ${PROJECT_NAME} - DESTINATION lib/${PROJECT_NAME} - ) - - ament_target_dependencies(${PROJECT_NAME} - gz-sensors${GZ_SENSORS_VER} - ${GZ_RENDERING_TARGET} - gz-msgs${GZ_MSGS_VER} - gz-transport${GZ_TRANSPORT_VER} - gz-sim${GZ_SIM_VER} - rclcpp - sensor_msgs - rosidl_default_generators - OpenCV - marine_acoustic_msgs - geometry_msgs - cv_bridge - ) - - ament_environment_hooks( - "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in" - ) +if(TARGET cv_bridge::cv_bridge) + target_link_libraries(${PROJECT_NAME} cv_bridge::cv_bridge) +elseif(TARGET cv_bridge) + target_link_libraries(${PROJECT_NAME} cv_bridge) +else() + target_link_libraries(${PROJECT_NAME} ${cv_bridge_LIBRARIES}) +endif() +if(TARGET gz-sensors::gz-sensors-rendering) + target_link_libraries(${PROJECT_NAME} gz-sensors::gz-sensors-rendering) else() - message(STATUS "CUDA Toolkit not found or disabled: " - "Skipping CUDA-specific targets") + find_library(GZ_SENSORS_RENDERING_LIB gz-sensors-rendering) + if(GZ_SENSORS_RENDERING_LIB) + target_link_libraries(${PROJECT_NAME} ${GZ_SENSORS_RENDERING_LIB}) + endif() endif() +install(TARGETS ${PROJECT_NAME} DESTINATION lib/${PROJECT_NAME}) + +ament_environment_hooks( + "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") + ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index 2840e20a..27841226 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -46,9 +46,9 @@ #include #include #include "MultibeamSonarSensor.hh" -#include "sonar_calculation_cuda.cuh" #include +#include #include #include #include @@ -142,7 +142,7 @@ class InMemoryTimeVaryingVectorField } if (this->zData && this->zSession) { - const auto interpolation = this->zData->LookUp(this->ySession.value(), _pos); + const auto interpolation = this->zData->LookUp(this->zSession.value(), _pos); outcome.Z(interpolation.value_or(0.)); } return outcome; @@ -183,6 +183,21 @@ MultibeamSonarSensor::MultibeamSonarSensor() : dataPtr(new Implementation()) {} ////////////////////////////////////////////////// MultibeamSonarSensor::~MultibeamSonarSensor() { + // Stop the background compute thread before tearing down sensors. + { + std::lock_guard lk(this->dataPtr->computeMutex_); + this->dataPtr->stopThread_ = true; + } + this->dataPtr->computeCV_.notify_one(); + if (this->dataPtr->computeThread_.joinable()) + { + this->dataPtr->computeThread_.join(); + } + + if (this->dataPtr->ros_executor_ && this->dataPtr->ros_node_) + { + this->dataPtr->ros_executor_->remove_node(this->dataPtr->ros_node_); + } this->dataPtr->rayConnection.reset(); this->dataPtr->sceneChangeConnection.reset(); // CSV log write stream close @@ -286,6 +301,24 @@ bool MultibeamSonarSensor::Implementation::Initialize(MultibeamSonarSensor * _se this->referenceFrameRotation = referenceFrameTransform.Rot().Inverse(); + // Spawn background sonar compute thread now that all setup is complete. + this->computeThread_ = std::thread( + [this]() + { + while (true) + { + std::unique_lock lk(this->computeMutex_); + this->computeCV_.wait(lk, [this] { return this->newFrameReady_ || this->stopThread_; }); + if (this->stopThread_) + { + break; + } + this->newFrameReady_ = false; + lk.unlock(); + this->ComputeSonarImage(); + } + }); + gzmsg << "Initialized [" << _sensor->Name() << "] sensor." << std::endl; this->initialized = true; return true; @@ -383,18 +416,31 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo sensorElement->Get("sonarImageTopicName", "sonar_image").first; gzmsg << "sonarImageTopicName: " << this->sonarImageTopicName << std::endl; + // NOTE: frameName is the TF frame name for published sonar messages (frame_id in ROS headers). + // This is explicitly configured in SDF rather than derived from Gazebo's + // internal sensor frame ID. Must match TF tree for RViz transforms to work correctly. + // Verify in SDF files that matches the expected TF frame name. this->frameName = sensorElement->Get("frameName", "forward_sonar_optical_link").first; gzmsg << "frameName: " << this->frameName << std::endl; - this->frameId = _sensor->FrameId(); - size_t pos = 0; - while ((pos = this->frameId.find("::", pos)) != std::string::npos) + if (const char * backendEnv = std::getenv("DAVE_SONAR_COMPUTE_BACKEND")) + { + this->requestedBackend = backendEnv; + } + else + { + this->requestedBackend = "auto"; + } + + this->computeBackend = CreateComputeBackend(this->requestedBackend); + if (!this->computeBackend) { - this->frameId.replace(pos, 2, "/"); - pos += 1; + gzerr << "Unable to initialize requested sonar backend [" << this->requestedBackend << "]." + << std::endl; + return false; } - gzmsg << "frameId: " << this->frameId << std::endl; + gzmsg << "Using sonar compute backend: " << this->computeBackend->Name() << std::endl; // ROS Initialization @@ -404,7 +450,8 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo } this->ros_node_ = std::make_shared("multibeam_sonar_node"); - this->ros_node_->set_parameters({rclcpp::Parameter("use_sim_time", true)}); + this->ros_executor_ = std::make_shared(); + this->ros_executor_->add_node(this->ros_node_); // Create the point cloud publisher this->pointPub = this->node.Advertise( @@ -689,6 +736,36 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo this->constMu = true; this->mu = 1e-3; + SonarComputeInput prototype; + prototype.depthImage = &this->pointCloudImage; + prototype.nBeams = this->nBeams; + prototype.nRays = this->nRays; + prototype.nFreq = this->nFreq; + prototype.raySkips = this->raySkips; + prototype.hFOV = this->hFOV; + prototype.vFOV = this->vFOV; + prototype.maxDistance = this->maxDistance; + prototype.soundSpeed = this->soundSpeed; + prototype.attenuation = this->attenuation; + prototype.sensorGain = this->sensorGain; + prototype.blazingFlag = this->blazingFlag; + prototype.window = this->window; + prototype.rangeVector = this->rangeVector; + prototype.beamCorrector = this->beamCorrector; + prototype.beamCorrectorSum = this->beamCorrectorSum; + prototype.sourceLevel = this->sourceLevel; + prototype.bandwidth = this->bandwidth; + prototype.sonarFreq = this->sonarFreq; + prototype.elevation_angles.assign(this->elevation_angles, this->elevation_angles + this->nRays); + prototype.seed = this->sonarSeed; + + if (!this->computeBackend->Initialize(prototype)) + { + gzerr << "Failed to initialize sonar backend [" << this->computeBackend->Name() << "]." + << std::endl; + return false; + } + return true; } @@ -724,9 +801,14 @@ void MultibeamSonarSensor::Implementation::OnNewFrame( // Fill point cloud with the ray buffer this->FillPointCloudMsg(this->rayBuffer); + // Signals the background compute thread to process the new frame if (this->pointCloudImage.size().width != 0) { - this->ComputeSonarImage(); + { + std::lock_guard lk(this->computeMutex_); + this->newFrameReady_ = true; + } + this->computeCV_.notify_one(); } } @@ -805,7 +887,10 @@ void MultibeamSonarSensor::PostUpdate(const std::chrono::steady_clock::duration << "cannot estimate velocities." << std::endl; return; } - rclcpp::spin_some(this->dataPtr->ros_node_); + if (this->dataPtr->ros_executor_) + { + this->dataPtr->ros_executor_->spin_some(); + } if (this->dataPtr->publishingPointCloud) { @@ -998,9 +1083,11 @@ cv::Mat MultibeamSonarSensor::Implementation::ComputeNormalImage(cv::Mat & depth images.at(0) = n1; // for green channel images.at(1) = n2; // for red channel - // Calculate focal length (?) Not sure if this is the right way; - // I could not find in the original DAVE the definition of the focal length - double focal_length = (0.5 * this->pointMsg.width()) / tan(0.5 * this->hFOV); + // Calculate focal length using the actual depth image dimensions (cols) so + // this function is safe to call from a background thread without accessing + // the shared pointMsg field. Avoid data race with pointMsg.width() which may + // be being written to by the render thread concurrently. + double focal_length = (0.5 * depth.cols) / tan(0.5 * this->hFOV); images.at(2) = 1.0 / focal_length * depth; // for blue channel cv::Mat normal_image; @@ -1043,21 +1130,51 @@ void MultibeamSonarSensor::Implementation::ComputeCorrector() void MultibeamSonarSensor::Implementation::ComputeSonarImage() { - this->lock_.lock(); - cv::Mat normal_image = this->ComputeNormalImage(this->pointCloudImage); - double vPixelSize = this->vFOV / (this->pointMsg.height() - 1); - double hPixelSize = this->hFOV / (this->pointMsg.width() - 1); - - if (this->beamCorrectorSum == 0) + // Snapshot the depth image quickly, then release the lock so the render + // thread can fill the next frame without waiting for GPU compute. + cv::Mat depthSnapshot; + // Capture the timestamp and image dimensions while holding the lock so that + // all published messages share one coherent stamp matching when the data was + // recorded -- mismatched stamps are the primary cause of RViz flicker. + rclcpp::Time capturedStamp; { - ComputeCorrector(); - } + std::lock_guard snapshot_lk(this->lock_); + if (this->pointCloudImage.empty()) + { + return; + } + depthSnapshot = this->pointCloudImage.clone(); + capturedStamp = this->ros_node_->now(); - if (this->reflectivityImage.rows == 0) - { - this->reflectivityImage = - cv::Mat(this->pointMsg.width(), this->pointMsg.height(), CV_32FC1, cv::Scalar(this->mu)); + if (this->beamCorrectorSum == 0) + { + ComputeCorrector(); + } + + if (this->reflectivityImage.rows == 0) + { + this->reflectivityImage = + cv::Mat(depthSnapshot.rows, depthSnapshot.cols, CV_32FC1, cv::Scalar(this->mu)); + } } + // lock_ released -> render thread can proceed immediately. + + // NOTE: reflectivityImage is used here without a lock. + // This is safe for now because only the compute thread writes to it + // and the render thread only reads it. If the render thread ever starts modifying + // reflectivityImage, this will cause a data race. + // TODO: Either guarantee that reflectivityImage stays read-only + // after initialization, or add proper locking around the compute step. + + // Use snapshot dimensions from here on to avoid racing with the render thread + // that may be writing pointMsg concurrently. + const int snapWidth = depthSnapshot.cols; + const int snapHeight = depthSnapshot.rows; + + double vPixelSize = this->vFOV / (snapHeight - 1); + double hPixelSize = this->hFOV / (snapWidth - 1); + + cv::Mat normal_image = this->ComputeNormalImage(depthSnapshot); auto start = std::chrono::high_resolution_clock::now(); @@ -1065,35 +1182,49 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() // -------- Sonar calculations -------- // // ------------------------------------------------// - CArray2D P_Beams = NpsGazeboSonar::sonar_calculation_wrapper( - this->pointCloudImage, // cv::Mat& depth_image (the point cloud image) - normal_image, // cv::Mat& normal_image - hPixelSize, // hPixelSize - vPixelSize, // vPixelSize - hFOV, // hFOV - vFOV, // VFOV - hPixelSize, // _beam_azimuthAngleWidth - verticalFOV / 180 * M_PI, // _beam_elevationAngleWidth - hPixelSize, // _ray_azimuthAngleWidth - this->elevation_angles, // _ray_elevationAngles - vPixelSize * (raySkips + 1), // _ray_elevationAngleWidth - this->soundSpeed, // _soundSpeed - this->maxDistance, // _maxDistance - this->sourceLevel, // _sourceLevel - this->nBeams, // _nBeams - this->nRays, // _nRays - this->raySkips, // _raySkips - this->sonarFreq, // _sonarFreq - this->bandwidth, // _bandwidth - this->nFreq, // _nFreq - this->reflectivityImage, // reflectivity_image - this->attenuation, // _attenuation - this->window, // _window - this->beamCorrector, // _beamCorrector - this->beamCorrectorSum, // _beamCorrectorSum - this->debugFlag, // debugFlag - this->blazingFlag // _blazingFlag - ); + SonarComputeInput input; + input.depthImage = &depthSnapshot; + input.normalImage = &normal_image; + input.reflectivityImage = &this->reflectivityImage; + input.nBeams = this->nBeams; + input.nRays = this->nRays; + input.nFreq = this->nFreq; + input.raySkips = this->raySkips; + input.hFOV = this->hFOV; + input.vFOV = this->vFOV; + input.maxDistance = this->maxDistance; + input.soundSpeed = this->soundSpeed; + input.attenuation = this->attenuation; + input.sensorGain = this->sensorGain; + input.blazingFlag = this->blazingFlag; + input.window = this->window; + input.rangeVector = this->rangeVector; + input.beamCorrector = this->beamCorrector; + input.beamCorrectorSum = this->beamCorrectorSum; + input.sourceLevel = this->sourceLevel; + input.bandwidth = this->bandwidth; + input.sonarFreq = this->sonarFreq; + input.elevation_angles.assign(this->elevation_angles, this->elevation_angles + this->nRays); + input.frameIndex = this->frameCounter++; + input.seed = this->sonarSeed; + + SonarComputeOutput backendOutput; + if (!this->computeBackend->Compute(input, backendOutput)) + { + RCLCPP_ERROR_STREAM( + this->ros_node_->get_logger(), + "Sonar compute backend failed: " << this->computeBackend->Name()); + return; + } + + CArray2D P_Beams(CArray(this->nFreq), this->nBeams); + for (int beam = 0; beam < this->nBeams; ++beam) + { + for (int freq = 0; freq < this->nFreq; ++freq) + { + P_Beams[beam][freq] = backendOutput.At(beam, freq); + } + } // For calc time measure auto stop = std::chrono::high_resolution_clock::now(); @@ -1102,8 +1233,9 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() if (debugFlag) { RCLCPP_INFO_STREAM( - this->ros_node_->get_logger(), - "GPU Sonar Frame Calc Time " << duration.count() / 10000 << "/100 [s]\n"); + this->ros_node_->get_logger(), this->computeBackend->Name() + << " Sonar Frame Calc Time " << duration.count() / 10000 + << "/100 [s]\n"); } // CSV log write stream @@ -1164,13 +1296,22 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() } } - rclcpp::Time now = this->ros_node_->now(); + // Use the single timestamp captured at snapshot time for all publications. + // This ensures sonarRawDataMsg, sonarImgMsg, and normalImgMsg all carry the + // same stamp - the sim time when the depth frame was actually recorded - + // so RViz TF lookups succeed and images don't flicker. - this->sonarRawDataMsg.header.frame_id = this->frameId; + // FEEDBACK_NEEDED/TODO: Using frameName (from SDF tag) as the ROS frame_id. + // This is user-friendly but requires the SDF value to match an actual TF frame exactly. + // The old approach used frameId (Gazebo's internal sensor ID) which was more likely + // to match the TF tree automatically. + // If TF lookup errors appear in RViz, shall revert back to frameId. + + this->sonarRawDataMsg.header.frame_id = this->frameName; - this->sonarRawDataMsg.header.stamp.sec = static_cast(now.seconds()); + this->sonarRawDataMsg.header.stamp.sec = static_cast(capturedStamp.seconds()); this->sonarRawDataMsg.header.stamp.nanosec = - static_cast(now.nanoseconds() % 1000000000); + static_cast(capturedStamp.nanoseconds() % 1000000000); marine_acoustic_msgs::msg::PingInfo ping_info_msg_; @@ -1308,10 +1449,10 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() cv::Mat Itensity_image_color; cv::applyColorMap(Intensity_image, Itensity_image_color, cv::COLORMAP_HOT); - now = this->ros_node_->now(); this->sonarImgMsg.header.frame_id = this->frameName; - this->sonarImgMsg.header.stamp.sec = static_cast(now.seconds()); - this->sonarImgMsg.header.stamp.nanosec = static_cast(now.nanoseconds() % 1000000000); + this->sonarImgMsg.header.stamp.sec = static_cast(capturedStamp.seconds()); + this->sonarImgMsg.header.stamp.nanosec = + static_cast(capturedStamp.nanoseconds() % 1000000000); img_bridge = cv_bridge::CvImage( this->sonarImgMsg.header, sensor_msgs::image_encodings::BGR8, Itensity_image_color); @@ -1319,12 +1460,10 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() img_bridge.toImageMsg(this->sonarImgMsg); this->sonarImagePub->publish(this->sonarImgMsg); - // ---------------------------------------- End of sonar calculation - - now = this->ros_node_->now(); this->normalImgMsg.header.frame_id = this->frameName; - this->normalImgMsg.header.stamp.sec = static_cast(now.seconds()); - this->normalImgMsg.header.stamp.nanosec = static_cast(now.nanoseconds() % 1000000000); + this->normalImgMsg.header.stamp.sec = static_cast(capturedStamp.seconds()); + this->normalImgMsg.header.stamp.nanosec = + static_cast(capturedStamp.nanoseconds() % 1000000000); cv::Mat normal_image8; normal_image.convertTo(normal_image8, CV_8UC3, 255.0); @@ -1334,7 +1473,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() // from cv_bridge to sensor_msgs::Image this->normalImagePub->publish(this->normalImgMsg); - this->lock_.unlock(); + // ---------------------------------------- End of sonar calculation } } // namespace sensors diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh index 3138bd10..7e3b65d2 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh @@ -3,9 +3,11 @@ #include #include +#include #include #include #include +#include #include #include #include @@ -24,6 +26,7 @@ #include #include "AcousticBeam.hh" #include "AxisAlignedPatch2.hh" +#include "sonar_compute_backend.hh" namespace gz { @@ -108,8 +111,16 @@ private: GZ_UTILS_WARN_IGNORE__DLL_INTERFACE_MISSING mutable std::mutex rayMutex; GZ_UTILS_WARN_RESUME__DLL_INTERFACE_MISSING + + // Background sonar compute thread and synchronization + std::thread computeThread_; + std::condition_variable computeCV_; + std::mutex computeMutex_; + bool newFrameReady_{false}; + bool stopThread_{false}; // ROS node pointer std::shared_ptr ros_node_; + std::shared_ptr ros_executor_; // SDF sensor element sdf::ElementPtr sensorSdf; @@ -153,7 +164,6 @@ private: std::string sonarImageRawTopicName; std::string sonarImageTopicName; std::string frameName; - std::string frameId; // for non-optical frame id from sensor // Ray parameters int raySkips; @@ -169,6 +179,12 @@ private: bool constMu; double mu; + // Backend selection and determinism controls. + std::string requestedBackend{"auto"}; + uint64_t sonarSeed{12345}; + uint64_t frameCounter{0}; + std::unique_ptr computeBackend; + // Beam corrector float beamCorrectorSum; float ** beamCorrector; diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml index 593fd5de..5e632334 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml @@ -7,7 +7,7 @@ Helena Moyen Gaurav kumar Apache 2.0 - ament_cmake + ament_cmake rclcpp sensor_msgs geometry_msgs @@ -17,6 +17,7 @@ pcl_conversions dave_interfaces protobuf + wgpu_vendor ament_lint_auto ament_cmake diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh new file mode 100644 index 00000000..b0750763 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh @@ -0,0 +1,83 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include + +namespace gz +{ +namespace sensors +{ + +struct SonarComputeInput +{ + const cv::Mat * depthImage{nullptr}; + const cv::Mat * normalImage{nullptr}; + const cv::Mat * reflectivityImage{nullptr}; + + int nBeams{0}; + int nRays{0}; + int nFreq{0}; + int raySkips{1}; + + double hFOV{0.0}; + double vFOV{0.0}; + double maxDistance{0.0}; + double soundSpeed{1500.0}; + double attenuation{0.0}; + + float sensorGain{0.02f}; + bool blazingFlag{false}; + + const float * window{nullptr}; + const float * rangeVector{nullptr}; + float ** beamCorrector{nullptr}; + float beamCorrectorSum{0.0f}; + + double sourceLevel{220.0}; + double bandwidth{29.5e6}; + double sonarFreq{900e3}; + std::vector elevation_angles; + + uint64_t frameIndex{0}; + uint64_t seed{0}; +}; + +struct SonarComputeOutput +{ + int nBeams{0}; + int nFreq{0}; + std::vector> beamSpectrum; + double computeMicros{0.0}; + + std::complex & At(int beam, int freq) + { + return this->beamSpectrum[static_cast(beam) * this->nFreq + freq]; + } + + const std::complex & At(int beam, int freq) const + { + return this->beamSpectrum[static_cast(beam) * this->nFreq + freq]; + } +}; + +class ComputeBackend +{ +public: + virtual ~ComputeBackend() = default; + + virtual const char * Name() const = 0; + + virtual bool Initialize(const SonarComputeInput & prototype) = 0; + + virtual bool Compute(const SonarComputeInput & input, SonarComputeOutput & output) = 0; +}; + +std::unique_ptr CreateComputeBackend(const std::string & requestedBackend); + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc new file mode 100644 index 00000000..b997c187 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc @@ -0,0 +1,179 @@ +#include "sonar_compute_backend.hh" + +#include +#include +#include + +// NOTE: This file serves two responsibilities -- it implements the CPU compute +// backend (CpuComputeBackend) AND hosts the CreateComputeBackend() factory +// that selects between CPU and WGPU at runtime. Because the factory lives here, +// this file must include sonar_compute_wgpu.hh, which creates a hard compile-time +// dependency on the WGPU backend even when only CPU support is needed. +// +// TODO: Move CreateComputeBackend() into its own file (e.g. sonar_compute_factory.cc) +// so that the CPU and WGPU backends are fully decoupled and the factory is the +// only translation unit that needs to know about both. +#include "sonar_compute_wgpu.hh" + +namespace gz +{ +namespace sensors +{ +namespace +{ + +class CpuComputeBackend : public ComputeBackend +{ +public: + const char * Name() const override { return "cpu"; } + + bool Initialize(const SonarComputeInput &) override { return true; } + + bool Compute(const SonarComputeInput & input, SonarComputeOutput & output) override + { + if (!input.depthImage || !input.normalImage || input.nBeams <= 0 || input.nFreq <= 0) + { + return false; + } + + auto start = std::chrono::high_resolution_clock::now(); + + output.nBeams = input.nBeams; + output.nFreq = input.nFreq; + output.beamSpectrum.assign( + static_cast(input.nBeams) * static_cast(input.nFreq), + std::complex(0.0f, 0.0f)); + + const cv::Mat & depth = *input.depthImage; + const cv::Mat & normal = *input.normalImage; + + if (depth.empty() || depth.type() != CV_32FC1 || normal.empty() || normal.type() != CV_32FC3) + { + return false; + } + + const int rows = depth.rows; + const int cols = depth.cols; + const float maxDistance = static_cast(std::max(1e-6, input.maxDistance)); + const float attenuation = static_cast(input.attenuation); + + const float source_term = + std::sqrt(std::pow(10.0f, static_cast(input.sourceLevel) / 10.0f)) * 1e-6f; + const float h_pixel = static_cast(input.hFOV) / std::max(1, input.nBeams - 1); + const float v_pixel = static_cast(input.vFOV) / std::max(1, input.nRays - 1); + const float area_scaler = h_pixel * v_pixel * static_cast(input.raySkips + 1); + + for (int r = 0; r < rows; ++r) + { + const float * depthRow = depth.ptr(r); + const cv::Vec3f * normalRow = normal.ptr(r); + for (int c = 0; c < cols; ++c) + { + const float d = depthRow[c]; + if (!std::isfinite(d) || d <= 0.0f || d > maxDistance) + { + continue; + } + + const int beam = std::min(input.nBeams - 1, (c * input.nBeams) / std::max(1, cols)); + const int bin = + std::min(input.nFreq - 1, static_cast((d / maxDistance) * input.nFreq)); + + const float nz = std::max(0.0f, normalRow[c][2]); + float reflectivity = 1.0f; + if (input.reflectivityImage && !input.reflectivityImage->empty()) + { + reflectivity = input.reflectivityImage->at(r, c); + } + + // CUDA-aligned physics: spread + lambert + stochastic phase term + const float lambert = std::sqrt(nz); // sqrt(cos(incidence)); nz == cos(acos(nz)) + const float spread = 1.0f / std::max(1e-6f, d * d); + const float target_area = std::sqrt(d * area_scaler); + const float amplitude_mag = input.sensorGain * source_term * spread * + std::exp(-2.0f * attenuation * d) * lambert * + std::sqrt(reflectivity) * target_area; + + const float phase_noise = static_cast((beam * input.nRays + r) % input.nFreq) / + static_cast(input.nFreq) * 2.0f * static_cast(M_PI); + output.At(beam, bin) += std::complex( + amplitude_mag * std::cos(phase_noise) / std::sqrt(2.0f), + amplitude_mag * std::sin(phase_noise) / std::sqrt(2.0f)); + } + } + + // Apply the configured range window. + if (input.window) + { + for (int b = 0; b < input.nBeams; ++b) + { + for (int f = 0; f < input.nFreq; ++f) + { + output.At(b, f) *= input.window[f]; + } + } + } + + // Apply beam correction matrix when available. + if (input.beamCorrector && input.beamCorrectorSum > 0.0f) + { + std::vector> corrected = output.beamSpectrum; + const float norm = input.beamCorrectorSum; + for (int b = 0; b < input.nBeams; ++b) + { + for (int f = 0; f < input.nFreq; ++f) + { + std::complex accum{0.0f, 0.0f}; + for (int k = 0; k < input.nBeams; ++k) + { + const float w = input.beamCorrector[b][k] / norm; + accum += output.At(k, f) * w; + } + corrected[static_cast(b) * input.nFreq + f] = accum; + } + } + output.beamSpectrum.swap(corrected); + } + + const auto stop = std::chrono::high_resolution_clock::now(); + output.computeMicros = + std::chrono::duration_cast(stop - start).count(); + return true; + } +}; + +} // namespace + +std::unique_ptr CreateComputeBackend(const std::string & requestedBackend) +{ + std::string backend = requestedBackend; + std::transform( + backend.begin(), backend.end(), backend.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + + if (backend == "wgpu") + { + auto wgpuBackend = std::make_unique(); + SonarComputeInput probe; + if (wgpuBackend->Initialize(probe)) + { + return wgpuBackend; + } + return nullptr; + } + + if (backend == "auto") + { + auto wgpuBackend = std::make_unique(); + SonarComputeInput probe; + if (wgpuBackend->Initialize(probe)) + { + return wgpuBackend; + } + } + + return std::make_unique(); +} + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc new file mode 100644 index 00000000..698b1fd1 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc @@ -0,0 +1,214 @@ +#include "sonar_compute_wgpu.hh" + +#include +#include +#include +#include +#include +#include + +extern "C" +{ + float * sonar_wgpu_compute( + const float * depth_flat, const float * normal_flat, const float * reflectivity_flat, + const float * beam_corrector_flat, const float * window_flat, uint32_t n_beams, uint32_t n_rays, + uint32_t n_freq, uint32_t ray_skips, float sound_speed, float max_distance, float source_level, + float attenuation, float sensor_gain, float bandwidth, float beam_corrector_sum, + float area_scaler, float h_fov, float v_fov, uint64_t frame_index, uint64_t seed); + + void sonar_wgpu_free(float * ptr, size_t len); +} + +namespace gz +{ +namespace sensors +{ + +const char * WgpuComputeBackend::Name() const { return "wgpu"; } + +bool WgpuComputeBackend::Initialize(const SonarComputeInput &) +{ + // Probe the Rust FFI to check if GPU is available. + // If probe returns null, GPU is unavailable -> we'll fall back to CPU in Compute(). + std::vector depth(1, 0.0f); + std::vector normal(3, 0.0f); + std::vector reflectivity(1, 0.0f); + std::vector beamCorrector(1, 1.0f); + std::vector window(4, 1.0f); + + float * probe = sonar_wgpu_compute( + depth.data(), normal.data(), reflectivity.data(), beamCorrector.data(), window.data(), 1u, 1u, + 4u, 0u, 1500.0f, 10.0f, 220.0f, 0.0f, 1.0f, 29500000.0f, 1.0f, 1e-6f, 0.0f, 0.0f, 0u, 1u); + + if (probe) + { + sonar_wgpu_free(probe, 1u * 4u * 2u); + this->gpuAvailable = true; + } + else + { + this->gpuAvailable = false; + std::cerr << "[sonar_wgpu] GPU probe failed -> will fall back to CPU at compute time." + << std::endl; + } + + this->initialized = true; + return true; +} + +bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOutput & output) +{ + if (!this->initialized) + { + return false; + } + + // If GPU is unavailable, go directly to CPU fallback + if (!this->gpuAvailable) + { + if (!this->cpuFallback) + { + this->cpuFallback = CreateComputeBackend("cpu"); + if (this->cpuFallback) + { + this->cpuFallback->Initialize(input); + } + } + if (this->cpuFallback) + { + return this->cpuFallback->Compute(input, output); + } + return false; + } + + if (!input.depthImage || !input.normalImage || !input.reflectivityImage) + { + return false; + } + + const cv::Mat & depth = *input.depthImage; + const cv::Mat & normal = *input.normalImage; + const cv::Mat & reflectivity = *input.reflectivityImage; + + if (depth.empty() || normal.empty() || reflectivity.empty()) + { + return false; + } + + const int nBeams = input.nBeams; + const int nRays = input.nRays; + const int nFreq = input.nFreq; + + if (nBeams <= 0 || nRays <= 0 || nFreq <= 0) + { + return false; + } + + auto start = std::chrono::high_resolution_clock::now(); + + // Compute area_scaler matching CUDA: ray_azimuthAngleWidth * ray_elevationAngleWidth + // where ray_azimuthAngleWidth = hPixelSize + // ray_elevationAngleWidth = vPixelSize * (raySkips + 1) + const float hPixelSize = static_cast(input.hFOV) / std::max(1, nBeams - 1); + const float vPixelSize = static_cast(input.vFOV) / std::max(1, nRays - 1); + + const int raySkipsFactor = std::max(1, input.raySkips); + const float area_scaler = hPixelSize * vPixelSize * raySkipsFactor; + + // This transposes from OpenCV's [row=ray][col=beam] to [beam][ray] for WGPU. + // Flatten as [beam][ray]. + std::vector depthFlat(static_cast(nBeams) * nRays, 0.0f); + std::vector reflFlat(static_cast(nBeams) * nRays, 0.0f); + std::vector normalFlat(static_cast(nBeams) * nRays * 3, 0.0f); + + for (int beam = 0; beam < nBeams; ++beam) + { + for (int ray = 0; ray < nRays; ++ray) + { + if (ray >= depth.rows || beam >= depth.cols) + { + continue; + } + const size_t idx = static_cast(beam) * nRays + ray; + depthFlat[idx] = depth.at(ray, beam); + reflFlat[idx] = reflectivity.at(ray, beam); + const cv::Vec3f n = normal.at(ray, beam); + normalFlat[idx * 3 + 0] = n[0]; + normalFlat[idx * 3 + 1] = n[1]; + normalFlat[idx * 3 + 2] = n[2]; + } + } + + std::vector beamCorrectorFlat(static_cast(nBeams) * nBeams, 0.0f); + if (input.beamCorrector) + { + for (int r = 0; r < nBeams; ++r) + { + for (int c = 0; c < nBeams; ++c) + { + beamCorrectorFlat[static_cast(r) * nBeams + c] = input.beamCorrector[r][c]; + } + } + } + + std::vector windowFlat(static_cast(nFreq), 1.0f); + if (input.window) + { + for (int i = 0; i < nFreq; ++i) + { + windowFlat[static_cast(i)] = input.window[i]; + } + } + + float * result = sonar_wgpu_compute( + depthFlat.data(), normalFlat.data(), reflFlat.data(), beamCorrectorFlat.data(), + windowFlat.data(), static_cast(nBeams), static_cast(nRays), + static_cast(nFreq), static_cast(std::max(0, input.raySkips)), + static_cast(input.soundSpeed), static_cast(input.maxDistance), + static_cast(input.sourceLevel), static_cast(input.attenuation), input.sensorGain, + static_cast(input.bandwidth), input.beamCorrectorSum, area_scaler, + static_cast(input.hFOV), static_cast(input.vFOV), input.frameIndex, input.seed); + + if (!result) + { + // GPU returned null -> fall back to CPU backend + if (!this->cpuFallback) + { + this->cpuFallback = CreateComputeBackend("cpu"); + if (this->cpuFallback) + { + this->cpuFallback->Initialize(input); + std::cerr << "[sonar_wgpu] GPU compute returned null -> falling back to CPU backend." + << std::endl; + } + } + if (this->cpuFallback) + { + return this->cpuFallback->Compute(input, output); + } + return false; + } + + output.nBeams = nBeams; + output.nFreq = nFreq; + output.beamSpectrum.assign(static_cast(nBeams) * nFreq, std::complex(0.0f, 0.0f)); + + for (int b = 0; b < nBeams; ++b) + { + for (int f = 0; f < nFreq; ++f) + { + const size_t base = (static_cast(b) * nFreq + f) * 2; + output.At(b, f) = std::complex(result[base], result[base + 1]); + } + } + + sonar_wgpu_free(result, static_cast(nBeams) * nFreq * 2); + + auto stop = std::chrono::high_resolution_clock::now(); + output.computeMicros = static_cast( + std::chrono::duration_cast(stop - start).count()); + return true; +} + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh new file mode 100644 index 00000000..deb79c89 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh @@ -0,0 +1,28 @@ +#pragma once + +#include + +#include "sonar_compute_backend.hh" + +namespace gz +{ +namespace sensors +{ + +class WgpuComputeBackend : public ComputeBackend +{ +public: + const char * Name() const override; + + bool Initialize(const SonarComputeInput & prototype) override; + + bool Compute(const SonarComputeInput & input, SonarComputeOutput & output) override; + +private: + bool initialized{false}; + bool gpuAvailable{false}; + std::unique_ptr cpuFallback; +}; + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt index 0c1b3234..04e9178f 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt @@ -2,24 +2,24 @@ cmake_minimum_required(VERSION 3.8) project(dave_multibeam_sonar_demo) find_package(ament_cmake REQUIRED) -find_package(CUDAToolkit QUIET) -if(CUDAToolkit_FOUND) - install( - DIRECTORY launch - DESTINATION share/${PROJECT_NAME} - ) +install( + DIRECTORY launch + DESTINATION share/${PROJECT_NAME} +) - install( - DIRECTORY rviz - DESTINATION share/${PROJECT_NAME} - ) +install( + DIRECTORY rviz + DESTINATION share/${PROJECT_NAME} +) + +install( + PROGRAMS + scripts/plotdata.py + DESTINATION lib/${PROJECT_NAME} +) ament_environment_hooks( "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") -else() - message(STATUS "CUDA Toolkit not found: Skipping CUDA-specific targets") -endif() - ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py index 56d88cc2..c8f1aadc 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py @@ -19,6 +19,7 @@ from launch import LaunchDescription from launch.actions import DeclareLaunchArgument from launch.actions import IncludeLaunchDescription +from launch.actions import SetEnvironmentVariable from launch.conditions import IfCondition from launch.launch_description_sources import PythonLaunchDescriptionSource from launch.substitutions import LaunchConfiguration @@ -27,6 +28,7 @@ def generate_launch_description(): + compute_backend = LaunchConfiguration("compute_backend") pkg_dave_demos = get_package_share_directory("dave_demos") multibeam_sonar_sim = IncludeLaunchDescription( PythonLaunchDescriptionSource( @@ -39,6 +41,7 @@ def generate_launch_description(): "x": "5.8", "z": "2", "yaw": "3.14", + "compute_backend": compute_backend, }.items(), ) @@ -56,8 +59,14 @@ def generate_launch_description(): return LaunchDescription( [ - multibeam_sonar_sim, + DeclareLaunchArgument( + "compute_backend", + default_value="auto", + description="Sonar backend selection: auto|wgpu|cpu", + ), DeclareLaunchArgument("rviz", default_value="true", description="Open RViz."), + SetEnvironmentVariable("DAVE_SONAR_COMPUTE_BACKEND", compute_backend), + multibeam_sonar_sim, rviz, ] ) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/package.xml index c8a3da98..3aba7743 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/package.xml @@ -6,6 +6,8 @@ Woen-Sug Choi Apache 2.0 ament_cmake + python3-numpy + python3-matplotlib ament_cmake diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py index 3929e443..675d62b5 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py @@ -1,3 +1,5 @@ +import os + import numpy as np import matplotlib.pyplot as plt @@ -10,11 +12,30 @@ maxRange = 5 xPlotRange = 10 yPlotRange = xPlotRange * np.cos(45 * np.pi / 180) -filename = "SonarRawData_000001.csv" -beam_angle_file = "SonarRawData_beam_angles.csv" +source_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) +inferred_root = source_root +candidates = [ + os.environ.get("DAVE_SOURCE_ROOT", ""), + os.getcwd(), + inferred_root, + os.path.join(inferred_root, "src", "dave"), +] +source_root = inferred_root +for candidate in candidates: + if candidate and os.path.exists(os.path.join(candidate, "models", "dave_worlds", "worlds")): + source_root = candidate + break + +results_dir = os.path.join(source_root, "results") +os.makedirs(results_dir, exist_ok=True) + +filename = os.path.join(results_dir, "SonarRawData_000001.csv") +beam_angle_file = os.path.join(results_dir, "SonarRawData_beam_angles.csv") bw = 29.9e3 plotSkips = 1 epsilon = 1e-10 +scatter_output_file = os.path.join(results_dir, "SonarScatter.png") +beam_output_file = os.path.join(results_dir, "SonarBeamProfiles.png") # ------------------------------------------- @@ -71,7 +92,8 @@ def load_complex_csv(filepath): plt.ylim(1.02 * np.array([-yPlotRange, yPlotRange])) plt.gca().set_facecolor("k") plt.tight_layout() -plt.show() +plt.savefig(scatter_output_file, dpi=200) +plt.close() # ------------------------------------------- # LINE PLOTS FOR INDIVIDUAL BEAMS @@ -91,4 +113,8 @@ def load_complex_csv(filepath): plt.suptitle("Beam Profiles") plt.tight_layout() -plt.show() +plt.savefig(beam_output_file, dpi=200) +plt.close() + +print(f"Saved {scatter_output_file}") +print(f"Saved {beam_output_file}") diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt index 7bdb72c5..cf18b197 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt @@ -2,69 +2,37 @@ cmake_minimum_required(VERSION 3.11.0 FATAL_ERROR) project(multibeam_sonar_system) find_package(ament_cmake REQUIRED) -find_package(CUDAToolkit QUIET) - -if(CUDAToolkit_FOUND) - find_package(gz-cmake3 REQUIRED) - find_package(gz-common5 REQUIRED COMPONENTS profiler) - find_package(gz-plugin2 REQUIRED COMPONENTS register) - find_package(gz-rendering8 REQUIRED OPTIONAL_COMPONENTS ogre ogre2) - find_package(gz-sim8 REQUIRED) - find_package(gz-sensors8 REQUIRED) - - set(GZ_COMMON_VER ${gz-common5_VERSION_MAJOR}) - set(GZ_PLUGIN_VER ${gz-plugin2_VERSION_MAJOR}) - set(GZ_SIM_VER ${gz-sim8_VERSION_MAJOR}) - set(GZ_RENDERING_VER ${gz-rendering8_VERSION_MAJOR}) - set(GZ_SENSORS_VER ${gz-sensors8_VERSION_MAJOR}) - - if(TARGET gz-rendering${GZ_RENDERING_VER}::ogre) - set(HAVE_OGRE TRUE) - set(GZ_RENDERING_TARGET gz-rendering${GZ_RENDERING_VER}-ogre) - add_definitions(-DWITH_OGRE) - endif() +find_package(gz-common REQUIRED COMPONENTS profiler) +find_package(gz-plugin REQUIRED COMPONENTS register) +find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) +find_package(gz-sim REQUIRED) +find_package(gz-sensors REQUIRED) + +if(TARGET gz-rendering::ogre) + add_definitions(-DWITH_OGRE) +endif() - if(TARGET gz-rendering${GZ_RENDERING_VER}::ogre2) - set(HAVE_OGRE2 TRUE) - set(GZ_RENDERING_TARGET gz-rendering${GZ_RENDERING_VER}-ogre2) - add_definitions(-DWITH_OGRE2) - endif() +if(TARGET gz-rendering::ogre2) + add_definitions(-DWITH_OGRE2) +endif() - add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) +add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) +if(TARGET multibeam_sonar) add_library(${PROJECT_NAME} SHARED MultibeamSonarSystem.cc) target_link_libraries(${PROJECT_NAME} - gz-common${GZ_COMMON_VER}::gz-common${GZ_COMMON_VER} - gz-plugin${GZ_PLUGIN_VER}::gz-plugin${GZ_PLUGIN_VER} - gz-sim${GZ_SIM_VER}::gz-sim${GZ_SIM_VER} - gz-sensors${GZ_SENSORS_VER}::gz-sensors${GZ_SENSORS_VER} - multibeam_sonar - ) - target_include_directories(${PROJECT_NAME} - PUBLIC - ../multibeam_sonar - ) - - ament_target_dependencies(${PROJECT_NAME} - gz-common${GZ_COMMON_VER} - ${GZ_RENDERING_TARGET} - gz-plugin${GZ_PLUGIN_VER} - gz-sim${GZ_SIM_VER} - gz-sensors${GZ_SENSORS_VER} - ) - - # Install targets - install(TARGETS ${PROJECT_NAME} - DESTINATION lib/${PROJECT_NAME} - ) - - # Environment hooks - ament_environment_hooks( - "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in" + gz-common::gz-common + gz-plugin::gz-plugin + gz-sim::gz-sim + gz-sensors::gz-sensors + multibeam_sonar ) + target_include_directories(${PROJECT_NAME} PUBLIC ../multibeam_sonar) + install(TARGETS ${PROJECT_NAME} DESTINATION lib/${PROJECT_NAME}) + ament_environment_hooks("${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") else() - message(STATUS "CUDA Toolkit not found: Skipping CUDA-specific targets") + message(STATUS "multibeam_sonar target unavailable: Skipping multibeam_sonar_system") endif() ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml index 8c9bf152..6f921a21 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml @@ -9,7 +9,9 @@ Apache 2.0 geometry_msgs ament_cmake + multibeam_sonar ament_lint_auto + ament_lint_common ament_cmake diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt new file mode 100644 index 00000000..511b06e1 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt @@ -0,0 +1,55 @@ +cmake_minimum_required(VERSION 3.11) +project(wgpu_vendor) + +find_package(ament_cmake REQUIRED) +find_package(Vulkan REQUIRED) + +find_program(CARGO_EXECUTABLE cargo REQUIRED) + +set(RUST_CRATE_DIR ${CMAKE_CURRENT_SOURCE_DIR}/sonar_wgpu_rust) +set(RUST_TARGET_DIR ${CMAKE_CURRENT_BINARY_DIR}/cargo_target) +set(RUST_PROFILE_DIR ${RUST_TARGET_DIR}/release) +set(RUST_LIB ${RUST_PROFILE_DIR}/libsonar_wgpu.a) + +# Track all Rust source files so colcon/CMake triggers cargo when they change. +file(GLOB_RECURSE RUST_SOURCES + ${RUST_CRATE_DIR}/src/*.rs + ${RUST_CRATE_DIR}/src/shaders/*.wgsl + ${RUST_CRATE_DIR}/Cargo.toml + ${RUST_CRATE_DIR}/Cargo.lock +) + +add_custom_command( + OUTPUT ${RUST_LIB} + COMMAND ${CARGO_EXECUTABLE} build --release --target-dir ${RUST_TARGET_DIR} + WORKING_DIRECTORY ${RUST_CRATE_DIR} + DEPENDS ${RUST_SOURCES} + COMMENT "Building sonar_wgpu Rust static library" + VERBATIM +) + +add_custom_target(sonar_wgpu_rust ALL DEPENDS ${RUST_LIB}) + +add_library(sonar_wgpu_rust_static STATIC IMPORTED GLOBAL) +set_target_properties(sonar_wgpu_rust_static PROPERTIES + IMPORTED_LOCATION ${RUST_LIB}) +add_dependencies(sonar_wgpu_rust_static sonar_wgpu_rust) + +add_library(wgpu_vendor INTERFACE) +add_dependencies(wgpu_vendor sonar_wgpu_rust_static) + +target_link_libraries(wgpu_vendor INTERFACE sonar_wgpu_rust_static dl pthread m Vulkan::Vulkan) + +add_library(wgpu::wgpu_vendor ALIAS wgpu_vendor) + +install(FILES ${RUST_LIB} DESTINATION lib) + +install(TARGETS wgpu_vendor EXPORT wgpu_vendorTargets) +install(EXPORT wgpu_vendorTargets + NAMESPACE wgpu:: + FILE wgpu_vendorTargets.cmake + DESTINATION share/${PROJECT_NAME}/cmake) + +ament_export_targets(wgpu_vendorTargets HAS_LIBRARY_TARGET) +ament_export_dependencies(Vulkan) +ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml new file mode 100644 index 00000000..17f5eb07 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml @@ -0,0 +1,12 @@ + + + wgpu_vendor + 0.1.0 + Vendor package exposing wgpu CMake target for DAVE sonar backend. + DAVE Maintainers + Apache-2.0 + ament_cmake + + ament_cmake + + diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock new file mode 100644 index 00000000..9e8ac211 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock @@ -0,0 +1,1156 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "ash" +version = "0.37.3+1.3.251" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39e9c3835d686b0a6084ab4234fcd1b07dbf6e4767dce60874b12356a25ecd4a" +dependencies = [ + "libloading 0.7.4", +] + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" + +[[package]] +name = "block" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd16c4719339c4530435d38e511904438d07cce7950afa3718a84ac36c10e89e" + +[[package]] +name = "codespan-reporting" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3538270d33cc669650c4b093848450d380def10c331d38c768e34cac80576e6e" +dependencies = [ + "termcolor", + "unicode-width", +] + +[[package]] +name = "com" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e17887fd17353b65b1b2ef1c526c83e26cd72e74f598a8dc1bee13a48f3d9f6" +dependencies = [ + "com_macros", +] + +[[package]] +name = "com_macros" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d375883580a668c7481ea6631fc1a8863e33cc335bf56bfad8d7e6d4b04b13a5" +dependencies = [ + "com_macros_support", + "proc-macro2", + "syn 1.0.109", +] + +[[package]] +name = "com_macros_support" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad899a1087a9296d5644792d7cb72b8e34c1bec8e7d4fbc002230169a6e8710c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics-types" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45390e6114f68f718cc7a830514a96f903cccd70d02a8f6d9f643ac4ba45afaf" +dependencies = [ + "bitflags 1.3.2", + "core-foundation", + "libc", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "d3d12" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b28bfe653d79bd16c77f659305b195b82bb5ce0c0eb2a4846b82ddbd77586813" +dependencies = [ + "bitflags 2.11.0", + "libloading 0.8.9", + "winapi", +] + +[[package]] +name = "document-features" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61" +dependencies = [ + "litrs", +] + +[[package]] +name = "either" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[package]] +name = "gl_generator" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a95dfc23a2b4a9a2f5ab41d194f8bfda3cabec42af4e39f08c339eb2a0c124d" +dependencies = [ + "khronos_api", + "log", + "xml-rs", +] + +[[package]] +name = "glow" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd348e04c43b32574f2de31c8bb397d96c9fcfa1371bd4ca6d8bdc464ab121b1" +dependencies = [ + "js-sys", + "slotmap", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "glutin_wgl_sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c8098adac955faa2d31079b65dc48841251f69efd3ac25477903fc424362ead" +dependencies = [ + "gl_generator", +] + +[[package]] +name = "gpu-alloc" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fbcd2dba93594b227a1f57ee09b8b9da8892c34d55aa332e034a228d0fe6a171" +dependencies = [ + "bitflags 2.11.0", + "gpu-alloc-types", +] + +[[package]] +name = "gpu-alloc-types" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98ff03b468aa837d70984d55f5d3f846f6ec31fe34bbb97c4f85219caeee1ca4" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "gpu-allocator" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f56f6318968d03c18e1bcf4857ff88c61157e9da8e47c5f29055d60e1228884" +dependencies = [ + "log", + "presser", + "thiserror", + "winapi", + "windows", +] + +[[package]] +name = "gpu-descriptor" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" +dependencies = [ + "bitflags 2.11.0", + "gpu-descriptor-types", + "hashbrown 0.15.5", +] + +[[package]] +name = "gpu-descriptor-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + +[[package]] +name = "hassle-rs" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af2a7e73e1f34c48da31fb668a907f250794837e08faa144fd24f0b8b741e890" +dependencies = [ + "bitflags 2.11.0", + "com", + "libc", + "libloading 0.8.9", + "thiserror", + "widestring", + "winapi", +] + +[[package]] +name = "hexf-parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" + +[[package]] +name = "indexmap" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7714e70437a7dc3ac8eb7e6f8df75fd8eb422675fc7678aff7364301092b1017" +dependencies = [ + "equivalent", + "hashbrown 0.16.1", +] + +[[package]] +name = "jni-sys" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" + +[[package]] +name = "js-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c" +dependencies = [ + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "khronos-egl" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6aae1df220ece3c0ada96b8153459b67eebe9ae9212258bb0134ae60416fdf76" +dependencies = [ + "libc", + "libloading 0.8.9", + "pkg-config", +] + +[[package]] +name = "khronos_api" +version = "3.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" + +[[package]] +name = "libc" +version = "0.2.183" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libloading" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7c4b02199fee7c5d21a5ae7d8cfa79a6ef5bb2fc834d6e9058e89c825efdc55" +dependencies = [ + "cfg-if", + "windows-link", +] + +[[package]] +name = "litrs" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "malloc_buf" +version = "0.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62bb907fe88d54d8d9ce32a3cceab4218ed2f6b7d35617cafe9adf84e43919cb" +dependencies = [ + "libc", +] + +[[package]] +name = "metal" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5637e166ea14be6063a3f8ba5ccb9a4159df7d8f6d61c02fc3d480b1f90dcfcb" +dependencies = [ + "bitflags 2.11.0", + "block", + "core-graphics-types", + "foreign-types", + "log", + "objc", + "paste", +] + +[[package]] +name = "naga" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e536ae46fcab0876853bd4a632ede5df4b1c2527a58f6c5a4150fe86be858231" +dependencies = [ + "arrayvec", + "bit-set", + "bitflags 2.11.0", + "codespan-reporting", + "hexf-parse", + "indexmap", + "log", + "num-traits", + "rustc-hash", + "spirv", + "termcolor", + "thiserror", + "unicode-xid", +] + +[[package]] +name = "ndk-sys" +version = "0.5.0+25.2.9519653" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c196769dd60fd4f363e11d948139556a344e79d451aeb2fa2fd040738ef7691" +dependencies = [ + "jni-sys", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "objc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" +dependencies = [ + "malloc_buf", +] + +[[package]] +name = "once_cell" +version = "1.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" + +[[package]] +name = "pollster" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22686f4785f02a4fcc856d3b3bb19bf6c8160d103f7a99cc258bddd0251dc7f2" + +[[package]] +name = "presser" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "profiling" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "range-alloc" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca45419789ae5a7899559e9512e58ca889e41f04f1f2445e9f4b290ceccd1d08" + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "rayon" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "renderdoc-sys" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" + +[[package]] +name = "rustc-hash" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "sonar_wgpu" +version = "0.1.0" +dependencies = [ + "bytemuck", + "pollster", + "rayon", + "wgpu", +] + +[[package]] +name = "spirv" +version = "0.3.0+sdk-1.3.268.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eda41003dc44290527a59b13432d4a0379379fa074b70174882adfbdfd917844" +dependencies = [ + "bitflags 2.11.0", +] + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasm-bindgen" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.114" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wgpu" +version = "0.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90e37c7b9921b75dfd26dd973fdcbce36f13dfa6e2dc82aece584e0ed48c355c" +dependencies = [ + "arrayvec", + "cfg-if", + "cfg_aliases", + "document-features", + "js-sys", + "log", + "naga", + "parking_lot", + "profiling", + "raw-window-handle", + "smallvec", + "static_assertions", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "wgpu-core", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-core" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d50819ab545b867d8a454d1d756b90cd5f15da1f2943334ca314af10583c9d39" +dependencies = [ + "arrayvec", + "bit-vec", + "bitflags 2.11.0", + "cfg_aliases", + "codespan-reporting", + "document-features", + "indexmap", + "log", + "naga", + "once_cell", + "parking_lot", + "profiling", + "raw-window-handle", + "rustc-hash", + "smallvec", + "thiserror", + "web-sys", + "wgpu-hal", + "wgpu-types", +] + +[[package]] +name = "wgpu-hal" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "172e490a87295564f3fcc0f165798d87386f6231b04d4548bca458cbbfd63222" +dependencies = [ + "android_system_properties", + "arrayvec", + "ash", + "bit-set", + "bitflags 2.11.0", + "block", + "cfg_aliases", + "core-graphics-types", + "d3d12", + "glow", + "glutin_wgl_sys", + "gpu-alloc", + "gpu-allocator", + "gpu-descriptor", + "hassle-rs", + "js-sys", + "khronos-egl", + "libc", + "libloading 0.8.9", + "log", + "metal", + "naga", + "ndk-sys", + "objc", + "once_cell", + "parking_lot", + "profiling", + "range-alloc", + "raw-window-handle", + "renderdoc-sys", + "rustc-hash", + "smallvec", + "thiserror", + "wasm-bindgen", + "web-sys", + "wgpu-types", + "winapi", +] + +[[package]] +name = "wgpu-types" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1353d9a46bff7f955a680577f34c69122628cc2076e1d6f3a9be6ef00ae793ef" +dependencies = [ + "bitflags 2.11.0", + "js-sys", + "web-sys", +] + +[[package]] +name = "widestring" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be" +dependencies = [ + "windows-core", + "windows-targets", +] + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "xml-rs" +version = "0.8.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml new file mode 100644 index 00000000..e09be284 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "sonar_wgpu" +version = "0.1.0" +edition = "2021" + +[lib] +crate-type = ["staticlib"] + +[dependencies] +wgpu = { version = "0.20" } +bytemuck = { version = "1", features = ["derive"] } +pollster = "0.3" +rayon = "1" + +[profile.release] +opt-level = 3 +lto = true diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs new file mode 100644 index 00000000..1bfe5103 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs @@ -0,0 +1,268 @@ +// Cooley-Tukey FFT and Bluestein's chirp-Z transform for non-power-of-two sizes + +/// CPU FFT: WGPU port of CUDA cuFFT. +/// Power-of-2 N: Cooley-Tukey O(N log N). Arbitrary N: Bluestein chirp-Z O(N log M). +/// CPU used for validation and CUDA non-power-of-2 fallback (GPU shader: power-of-2 only, N <= 4096). +use crate::ComplexF; +use rayon::prelude::*; + +fn is_power_of_two(x: usize) -> bool { + x != 0 && (x & (x - 1)) == 0 +} + +fn bit_reverse(mut x: usize, bits: usize) -> usize { + let mut y = 0usize; + for _ in 0..bits { + y = (y << 1) | (x & 1); + x >>= 1; + } + y +} + +/// Cooley-Tukey FFT: CUDA cuFFT Cooley-Tukey algorithm (CPU reference). +/// Requires N = power of 2. Use bluestein_fft() for arbitrary N (CUDA extension). +fn fft_in_place(data: &mut [ComplexF]) { + let n = data.len(); + if n <= 1 { + return; + } + + let bits = (n as f32).log2() as usize; + for i in 0..n { + let j = bit_reverse(i, bits); + if j > i { + data.swap(i, j); + } + } + + let mut len = 2usize; + while len <= n { + let ang = -2.0f32 * std::f32::consts::PI / len as f32; + let wlen_re = ang.cos(); + let wlen_im = ang.sin(); + + let mut i = 0usize; + while i < n { + let mut w_re = 1.0f32; + let mut w_im = 0.0f32; + let half = len / 2; + for j in 0..half { + let u = data[i + j]; + let t = data[i + j + half]; + + let v_re = t.re * w_re - t.im * w_im; + let v_im = t.re * w_im + t.im * w_re; + + data[i + j] = ComplexF { + re: u.re + v_re, + im: u.im + v_im, + }; + data[i + j + half] = ComplexF { + re: u.re - v_re, + im: u.im - v_im, + }; + + let nw_re = w_re * wlen_re - w_im * wlen_im; + let nw_im = w_re * wlen_im + w_im * wlen_re; + w_re = nw_re; + w_im = nw_im; + } + i += len; + } + len <<= 1; + } +} + +/// Inverse FFT: Matches CUDA cuFFT conjugate-symmetry trick. IFFT(x) = conj(FFT(conj(x))) / N. +/// Requires power-of-2 N matching forward FFT (CUDA cufftExecC2C standard). +fn ifft_in_place(data: &mut [ComplexF]) { + let n = data.len(); + for x in data.iter_mut() { + x.im = -x.im; + } + fft_in_place(data); + let inv = 1.0f32 / n as f32; + for x in data.iter_mut() { + x.re *= inv; + x.im = -x.im * inv; + } +} + +/// Bluestein chirp-Z: Non-CUDA extension for arbitrary N DFT. O(N log M) where M = smallest 2^k >= 2N-1. +/// ~30-50x faster than naive O(N²) DFT. CUDA cuFFT only supports power-of-2; this is CPU addon. +fn bluestein_fft(data: &mut [ComplexF]) { + let n = data.len(); + if n <= 1 { + return; + } + + // M = smallest power-of-two >= 2*n - 1 (ensures linear convolution fits) + let mut m = 1usize; + while m < 2 * n - 1 { + m <<= 1; + } + + // Chirp sequence: w[k] = exp(-j*pi*k^2/n) + // stored as (cos, sin) where the complex value is (cos, -sin) = cos - j*sin + let chirp: Vec<(f32, f32)> = (0..n) + .map(|k| { + let theta = std::f32::consts::PI * (k * k) as f32 / n as f32; + (theta.cos(), theta.sin()) // (cos θ, sin θ) so chirp = cos - j·sin + }) + .collect(); + + // a[k] = data[k] * chirp[k] = data[k] * exp(-j*pi*k^2/n), zero-padded to M + let mut a = vec![ComplexF::default(); m]; + for k in 0..n { + let (c, s) = chirp[k]; // chirp[k] = (c, -s) as complex + a[k].re = data[k].re * c + data[k].im * s; // Re[(a+jb)(c-js)] = ac+bs + a[k].im = data[k].im * c - data[k].re * s; // Im[(a+jb)(c-js)] = bc-as + } + + // h[k] = conj(chirp[k]) = exp(+j*pi*k^2/n), with wrap-around: h[M-k] = h[k] + let mut h = vec![ComplexF::default(); m]; + for k in 0..n { + let (c, s) = chirp[k]; + h[k] = ComplexF { re: c, im: s }; // conj(chirp[k]) = cos + j*sin + if k > 0 { + h[m - k] = h[k]; // symmetric fill for circular convolution + } + } + + // Circular convolution via FFT: Y = IFFT(FFT(a) * FFT(h)) + fft_in_place(&mut a); + fft_in_place(&mut h); + for i in 0..m { + let re = a[i].re * h[i].re - a[i].im * h[i].im; + let im = a[i].re * h[i].im + a[i].im * h[i].re; + a[i] = ComplexF { re, im }; + } + ifft_in_place(&mut a); + + // X[k] = a[k] * chirp[k] = a[k] * exp(-j*pi*k^2/n) + for k in 0..n { + let (c, s) = chirp[k]; + data[k].re = a[k].re * c + a[k].im * s; + data[k].im = a[k].im * c - a[k].re * s; + } +} + +/// Batched FFT: CPU dispatcher matching CUDA cuFFT batching. Routes to GPU (power-of-2 && <= 4096) or CPU fallback. +pub fn fft_batched(input: &[ComplexF], n_beams: usize, n_freq: usize) -> Vec { + // Collect into per-beam rows, transform in parallel, then flatten back. + let mut rows: Vec> = (0..n_beams) + .map(|b| input[b * n_freq..(b + 1) * n_freq].to_vec()) + .collect(); + + rows.par_iter_mut().for_each(|row| { + if is_power_of_two(row.len()) { + fft_in_place(row); + } else { + bluestein_fft(row); + } + }); + + rows.into_iter().flatten().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ComplexF; + + /// Reference O(N²) DFT used only in tests for correctness comparison. + fn naive_dft(data: &mut [ComplexF]) { + let n = data.len(); + let input: Vec = data.to_vec(); + for k in 0..n { + let mut re_sum = 0.0f32; + let mut im_sum = 0.0f32; + for j in 0..n { + let angle = -2.0 * std::f32::consts::PI * (k as f32) * (j as f32) / (n as f32); + re_sum += input[j].re * angle.cos() - input[j].im * angle.sin(); + im_sum += input[j].re * angle.sin() + input[j].im * angle.cos(); + } + data[k] = ComplexF { re: re_sum, im: im_sum }; + } + } + + fn nearly_eq(a: f32, b: f32) -> bool { + (a - b).abs() < 1e-3 + } + + fn nearly_eq_rel(a: f32, b: f32) -> bool { + let mag = b.abs().max(1e-6); + (a - b).abs() / mag < 1e-2 // 1% relative tolerance for f32 accumulation over N=399 + } + + #[test] + fn bluestein_matches_naive_dft_n3() { + let mut x_b = vec![ + ComplexF { re: 1.0, im: 0.0 }, + ComplexF { re: 2.0, im: -1.0 }, + ComplexF { re: 0.5, im: 3.0 }, + ]; + let mut x_d = x_b.clone(); + bluestein_fft(&mut x_b); + naive_dft(&mut x_d); + for (b, d) in x_b.iter().zip(x_d.iter()) { + assert!(nearly_eq(b.re, d.re), "re: {} vs {}", b.re, d.re); + assert!(nearly_eq(b.im, d.im), "im: {} vs {}", b.im, d.im); + } + } + + #[test] + fn bluestein_matches_naive_dft_n399() { + use std::f32::consts::PI; + let n = 399usize; + // Construct a chirp-like test signal + let input: Vec = (0..n) + .map(|k| ComplexF { + re: ((k as f32) * PI / 50.0).cos(), + im: ((k as f32) * PI / 75.0).sin(), + }) + .collect(); + let mut x_b = input.clone(); + let mut x_d = input.clone(); + bluestein_fft(&mut x_b); + naive_dft(&mut x_d); + // Compare first 5 and last 5 bins + for &i in &[0, 1, 2, 3, 4, 394, 395, 396, 397, 398] { + assert!(nearly_eq_rel(x_b[i].re, x_d[i].re), + "bin {i} re: {} vs {}", x_b[i].re, x_d[i].re); + assert!(nearly_eq_rel(x_b[i].im, x_d[i].im), + "bin {i} im: {} vs {}", x_b[i].im, x_d[i].im); + } + } + + #[test] + fn bluestein_impulse_gives_constant_spectrum() { + // DFT of [1, 0, 0, ..., 0] = [1, 1, 1, ..., 1] + let n = 399usize; + let mut x = vec![ComplexF::default(); n]; + x[0].re = 1.0; + bluestein_fft(&mut x); + for (i, v) in x.iter().enumerate() { + assert!(nearly_eq(v.re, 1.0), "bin {i} re = {} (expected 1)", v.re); + assert!(nearly_eq(v.im, 0.0), "bin {i} im = {} (expected 0)", v.im); + } + } +} + + +/* + +X[k] = Σ x[n] · e^(-j·2π·kn/N) + + = Σ x[n] · e^(-jπ(k² + n² - (k-n)²)/N) + + = e^(-jπk²/N) · Σ [x[n] · e^(-jπn²/N)] · e^(+jπ(k-n)²/N) + ─────────── ───────────────────── ──────────────── + post-chirp a[n] (pre-chirped) h[k-n] (filter) + +So the full Bluestein algorithm is: + 1. a[n] = x[n] · e^(-jπn²/N) premultiply by chirp + 2. Y[k] = (a ★ h)[k] convolve with conjugate chirp filter + 3. X[k] = Y[k] · e^(-jπk²/N) postmultiply by chirp + +*/ \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs new file mode 100644 index 00000000..a737df3f --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs @@ -0,0 +1,581 @@ +/// C-FFI interface, Buffer initialization and write ups and reads + Bind groups created + encoder setup + memory free +/// + Compute passes for each shader + gpu/cpu path selection + +mod fft; // CPU FFT + Bluestein +mod pipeline; // GPU context + buffer management + +use bytemuck::{Pod, Zeroable}; + +#[derive(Clone, Copy, Default)] +pub struct ComplexF { + pub re: f32, + pub im: f32, +} +use std::sync::atomic::{AtomicU64, Ordering}; +use wgpu::util::DeviceExt; + +fn is_power_of_two(x: usize) -> bool { + x != 0 && (x & (x - 1)) == 0 +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct BackscatterParams { + n_beams: u32, + n_rays: u32, + n_freq: u32, + ray_skips: u32, + sound_speed: f32, + max_distance: f32, + source_level: f32, + attenuation: f32, + sensor_gain: f32, + bandwidth: f32, + seed_lo: u32, + seed_hi: u32, + frame_lo: u32, // WGSL has no native u64 type. + frame_hi: u32, // Split frame index into two u32 - frame_lo and frame_hi + area_scaler: f32, + h_fov: f32, + v_fov: f32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct MatmulParams { + n_beams: u32, + n_freq: u32, + beam_corrector_sum: f32, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct FftParams { + n_beams: u32, + n_freq: u32, + log2_n: u32, +} + +// REMOVE THIS, UNNCESSARY +/// Single-buffer i32 readback kept for CPU-fallback / test paths. +#[allow(dead_code)] +fn readback_i32( + device: &wgpu::Device, + queue: &wgpu::Queue, + src: &wgpu::Buffer, + len: usize, +) -> Option> { + let bytes = (len * std::mem::size_of::()) as u64; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback_i32"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_i32_encoder"), + }); + encoder.copy_buffer_to_buffer(src, 0, &staging, 0, bytes); + queue.submit(std::iter::once(encoder.finish())); + device.poll(wgpu::Maintain::Wait); + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::Maintain::Wait); + let data = slice.get_mapped_range(); + let out = bytemuck::cast_slice::(&data).to_vec(); + drop(data); + staging.unmap(); + Some(out) +} + +#[allow(dead_code)] +/// Read two i32 buffers back to CPU in **one** encoder+submit+poll pair. +/// Returns (re_vec, im_vec). +fn readback_i32_pair( + device: &wgpu::Device, + queue: &wgpu::Queue, + src_re: &wgpu::Buffer, + src_im: &wgpu::Buffer, + len: usize, +) -> Option<(Vec, Vec)> { + let bytes = (len * std::mem::size_of::()) as u64; + let stg_re = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("stg_re"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let stg_im = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("stg_im"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_i32_pair"), + }); + enc.copy_buffer_to_buffer(src_re, 0, &stg_re, 0, bytes); + enc.copy_buffer_to_buffer(src_im, 0, &stg_im, 0, bytes); + queue.submit(std::iter::once(enc.finish())); + device.poll(wgpu::Maintain::Wait); + + stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::Maintain::Wait); + + let re_data = stg_re.slice(..).get_mapped_range(); + let im_data = stg_im.slice(..).get_mapped_range(); + let re = bytemuck::cast_slice::(&re_data).to_vec(); + let im = bytemuck::cast_slice::(&im_data).to_vec(); + drop(re_data); + drop(im_data); + stg_re.unmap(); + stg_im.unmap(); + Some((re, im)) +} + +// REMOVE THIS, UNNCESSARY +#[allow(dead_code)] +/// Read two f32 buffers back to CPU in **one** encoder+submit+poll pair. +fn readback_f32_pair( + device: &wgpu::Device, + queue: &wgpu::Queue, + src_re: &wgpu::Buffer, + src_im: &wgpu::Buffer, + len: usize, +) -> Option<(Vec, Vec)> { + let bytes = (len * std::mem::size_of::()) as u64; + let stg_re = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("stg_re_f32"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let stg_im = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("stg_im_f32"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_f32_pair"), + }); + enc.copy_buffer_to_buffer(src_re, 0, &stg_re, 0, bytes); + enc.copy_buffer_to_buffer(src_im, 0, &stg_im, 0, bytes); + queue.submit(std::iter::once(enc.finish())); + device.poll(wgpu::Maintain::Wait); + + stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::Maintain::Wait); + + let re_data = stg_re.slice(..).get_mapped_range(); + let im_data = stg_im.slice(..).get_mapped_range(); + let re = bytemuck::cast_slice::(&re_data).to_vec(); + let im = bytemuck::cast_slice::(&im_data).to_vec(); + drop(re_data); + drop(im_data); + stg_re.unmap(); + stg_im.unmap(); + Some((re, im)) +} + +// REMOVE THIS, UNNCESSARY +/// Single-buffer f32 readback kept for CPU-fallback / test paths. +#[allow(dead_code)] +fn readback_f32( + device: &wgpu::Device, + queue: &wgpu::Queue, + src: &wgpu::Buffer, + len: usize, +) -> Option> { + let bytes = (len * std::mem::size_of::()) as u64; + let staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("readback_f32"), + size: bytes, + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("readback_f32_encoder"), + }); + encoder.copy_buffer_to_buffer(src, 0, &staging, 0, bytes); + queue.submit(std::iter::once(encoder.finish())); + device.poll(wgpu::Maintain::Wait); + + let slice = staging.slice(..); + slice.map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::Maintain::Wait); + + let data = slice.get_mapped_range(); + let out = bytemuck::cast_slice::(&data).to_vec(); + drop(data); + staging.unmap(); + Some(out) +} + +/// The function receives flat arrays from C++ and returns a heap-allocated array. +#[no_mangle] +pub extern "C" fn sonar_wgpu_compute( + depth_flat: *const f32, + normal_flat: *const f32, + reflectivity_flat: *const f32, + beam_corrector_flat: *const f32, + window_flat: *const f32, + n_beams: u32, + n_rays: u32, + n_freq: u32, + ray_skips: u32, + sound_speed: f32, + max_distance: f32, + source_level: f32, + attenuation: f32, + sensor_gain: f32, + bandwidth: f32, + beam_corrector_sum: f32, + area_scaler: f32, + h_fov: f32, + v_fov: f32, + frame_index: u64, + seed: u64, +) -> *mut f32 { + if depth_flat.is_null() + || normal_flat.is_null() + || reflectivity_flat.is_null() + || beam_corrector_flat.is_null() + || window_flat.is_null() + { + return std::ptr::null_mut(); + } + + let n_beams_us = n_beams as usize; + let n_rays_us = n_rays as usize; + let n_freq_us = n_freq as usize; + + if n_beams_us == 0 || n_rays_us == 0 || n_freq_us == 0 { + return std::ptr::null_mut(); + } + + use std::sync::Once; + + let depth_len = n_beams_us * n_rays_us; + let normal_len = depth_len * 3; + let refl_len = depth_len; + let bc_len = n_beams_us * n_beams_us; + let spectrum_len = n_beams_us * n_freq_us; + + let depth = unsafe { std::slice::from_raw_parts(depth_flat, depth_len) }; + let normals = unsafe { std::slice::from_raw_parts(normal_flat, normal_len) }; + let reflectivity = unsafe { std::slice::from_raw_parts(reflectivity_flat, refl_len) }; + let beam_corrector = unsafe { std::slice::from_raw_parts(beam_corrector_flat, bc_len) }; + + let ctx = match pipeline::get_or_init() { + Some(c) => { + static GPU_LOG: Once = Once::new(); + GPU_LOG.call_once(|| eprintln!("[sonar_wgpu] GPU device acquired -> running on GPU.")); + c + } + None => { + static FALLBACK_LOG: Once = Once::new(); + FALLBACK_LOG.call_once(|| eprintln!("[sonar_wgpu] GPU init failed -> returning null (C++ will use CPU backend).")); + return std::ptr::null_mut(); + } + }; + let device = &ctx.device; + let queue = &ctx.queue; + + static GPU_FRAME_COUNT: AtomicU64 = AtomicU64::new(0); + let gpu_frame = GPU_FRAME_COUNT.fetch_add(1, Ordering::Relaxed); + let gpu_t0 = std::time::Instant::now(); + + let window_vals = unsafe { std::slice::from_raw_parts(window_flat, n_freq_us) }; + + // ---- Per-frame uniform buffers (tiny, always recreated) ---- + let backscatter_params = BackscatterParams { + n_beams, + n_rays, + n_freq, + ray_skips, + sound_speed, + max_distance, + source_level, + attenuation, + sensor_gain, + bandwidth, + seed_lo: (seed & 0xFFFF_FFFF) as u32, + seed_hi: (seed >> 32) as u32, + frame_lo: (frame_index & 0xFFFF_FFFF) as u32, + frame_hi: (frame_index >> 32) as u32, + area_scaler, + h_fov, + v_fov, + _pad0: 0, + _pad1: 0, + _pad2: 0, + }; + let bs_param_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("bs_param"), + contents: bytemuck::bytes_of(&backscatter_params), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let mm_params = MatmulParams { n_beams, n_freq, beam_corrector_sum }; + let mm_param_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mm_param"), + contents: bytemuck::bytes_of(&mm_params), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + // REMOVE THIS, UNNCESSARY + // ---- Persistent data buffers: allocate once per dimension set, reuse every frame ---- + let mut buf_guard = ctx.buffers.lock().unwrap(); + { + let needs_alloc = buf_guard.as_ref().map_or(true, |b| { + b.n_beams != n_beams || b.n_rays != n_rays || b.n_freq != n_freq + }); + if needs_alloc { + *buf_guard = Some(pipeline::SonarBuffers::new(device, n_beams, n_rays, n_freq)); + eprintln!("[sonar_wgpu] Persistent GPU buffers allocated for {}×{}×{}", + n_beams, n_rays, n_freq); + } + } + let buf = buf_guard.as_ref().unwrap(); + + // Upload frame data into persistent buffers (DMA, no GPU alloc) + queue.write_buffer(&buf.depth_buf, 0, bytemuck::cast_slice(depth)); + queue.write_buffer(&buf.normal_buf, 0, bytemuck::cast_slice(normals)); + queue.write_buffer(&buf.refl_buf, 0, bytemuck::cast_slice(reflectivity)); + queue.write_buffer(&buf.window_buf, 0, bytemuck::cast_slice(window_vals)); + queue.write_buffer(&buf.bc_buf, 0, bytemuck::cast_slice(beam_corrector)); + // Zero atomic accumulators (reused each frame) + let zeros_i32 = vec![0i32; spectrum_len]; + queue.write_buffer(&buf.out_re_i32, 0, bytemuck::cast_slice(&zeros_i32)); + queue.write_buffer(&buf.out_im_i32, 0, bytemuck::cast_slice(&zeros_i32)); + + // ---- Bind groups (metadata only -> no GPU allocation) ---- + let bs_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("bs_bg"), + layout: &ctx.bs_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: bs_param_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.depth_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.normal_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: buf.refl_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: buf.out_re_i32.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 5, resource: buf.out_im_i32.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 6, resource: buf.window_buf.as_entire_binding() }, + ], + }); + let conv_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("conv_bg"), + layout: &ctx.convert_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: buf.out_re_i32.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.out_im_i32.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.mm_re_in.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: buf.mm_im_in.as_entire_binding() }, + ], + }); + let mm_re_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mm_re_bg"), + layout: &ctx.mm_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: mm_param_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.bc_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.mm_re_in.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: buf.mm_re_out.as_entire_binding() }, + ], + }); + let mm_im_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("mm_im_bg"), + layout: &ctx.mm_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: mm_param_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.bc_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.mm_im_in.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: buf.mm_im_out.as_entire_binding() }, + ], + }); + + let spectrum_bytes = (spectrum_len * std::mem::size_of::()) as u64; + let gpu_fft = n_freq_us <= 4096 && is_power_of_two(n_freq_us); + + // ---- Single command encoder: all passes + staging copy in one GPU submission ---- + let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("sonar_enc"), + }); + + // Backscatter dispatch: depth/normals/refl → i32 fixed-point spectrum + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("bs_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&ctx.bs_pipeline); + pass.set_bind_group(0, &bs_bg, &[]); + let gx = (n_beams + 7) / 8; + let ray_step = std::cmp::max(1, ray_skips); + let reduced_rays = (n_rays + ray_step - 1) / ray_step; + let gy = (reduced_rays + 7) / 8; + pass.dispatch_workgroups(gx, gy, 1); + } + + // Convert dispatch: i32 fixed-point → f32 (GPU-side, eliminates CPU readback round-trip) + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("conv_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&ctx.convert_pipeline); + pass.set_bind_group(0, &conv_bg, &[]); + let gx = (spectrum_len as u32 + 63) / 64; + pass.dispatch_workgroups(gx, 1, 1); + } + + // Matmul dispatch: beam-corrector matrix multiply, re and im in same encoder + { + let gx = (n_freq + 15) / 16; + let gy = (n_beams + 15) / 16; + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("mm_re_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&ctx.mm_pipeline); + pass.set_bind_group(0, &mm_re_bg, &[]); + pass.dispatch_workgroups(gx, gy, 1); + } + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("mm_im_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&ctx.mm_pipeline); + pass.set_bind_group(0, &mm_im_bg, &[]); + pass.dispatch_workgroups(gx, gy, 1); + } + } + + // FFT Dispatch (optional, only if n_freq is power-of-two and <= 4096); re/im in same encoder + if gpu_fft { + // copy MM output → FFT buffers → dispatch fft shader → copy to staging + enc.copy_buffer_to_buffer(&buf.mm_re_out, 0, &buf.p_re_buf, 0, spectrum_bytes); + enc.copy_buffer_to_buffer(&buf.mm_im_out, 0, &buf.p_im_buf, 0, spectrum_bytes); + + let fft_params = FftParams { n_beams, n_freq, log2_n: n_freq_us.ilog2() }; + let fft_param_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("fft_param"), + contents: bytemuck::bytes_of(&fft_params), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let fft_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fft_bg"), + layout: &ctx.fft_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: fft_param_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.p_re_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.p_im_buf.as_entire_binding() }, + ], + }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("fft_pass"), + timestamp_writes: None, + }); + pass.set_pipeline(&ctx.fft_pipeline); + pass.set_bind_group(0, &fft_bg, &[]); + pass.dispatch_workgroups(n_beams, 1, 1); + } + // ... fft dispatch ... then copy FFT output to staging for readback, all in same encoder + enc.copy_buffer_to_buffer(&buf.p_re_buf, 0, &buf.stg_re, 0, spectrum_bytes); + enc.copy_buffer_to_buffer(&buf.p_im_buf, 0, &buf.stg_im, 0, spectrum_bytes); + } else { + // CPU FFT path: copy MM output directly to staging + enc.copy_buffer_to_buffer(&buf.mm_re_out, 0, &buf.stg_re, 0, spectrum_bytes); + enc.copy_buffer_to_buffer(&buf.mm_im_out, 0, &buf.stg_im, 0, spectrum_bytes); + } + + // Submit the entire frame in a single GPU command -> POLL 1 + queue.submit(std::iter::once(enc.finish())); + device.poll(wgpu::Maintain::Wait); + + // map_async is asynchronous -> it requests CPU access to the buffer but doesn't block. + // Map staging buffers (GPU work already done above) -> POLL 2 + buf.stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + buf.stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + device.poll(wgpu::Maintain::Wait); + + let (mut p_re, mut p_im) = { + let re_data = buf.stg_re.slice(..).get_mapped_range(); + let im_data = buf.stg_im.slice(..).get_mapped_range(); + let re = bytemuck::cast_slice::(&re_data).to_vec(); + let im = bytemuck::cast_slice::(&im_data).to_vec(); + (re, im) + }; + buf.stg_re.unmap(); + buf.stg_im.unmap(); + + // CPU FFT fallback (n_freq not power-of-two; uses Bluestein O(N log N)) + if !gpu_fft { + let fft_t0 = std::time::Instant::now(); + let mut complex = vec![ComplexF::default(); spectrum_len]; + for i in 0..spectrum_len { + complex[i].re = p_re[i]; + complex[i].im = p_im[i]; + } + let fallback = fft::fft_batched(&complex, n_beams_us, n_freq_us); + for i in 0..spectrum_len { + p_re[i] = fallback[i].re; + p_im[i] = fallback[i].im; + } + if gpu_frame == 0 || gpu_frame % 50 == 49 { + eprintln!("[sonar_wgpu] CPU FFT: {:6.1} ms ({} beams x {} freq)", + fft_t0.elapsed().as_secs_f64() * 1000.0, n_beams, n_freq); + } + } + + // Matches CUDA's post-FFT * delta_f scaling + let delta_f = bandwidth / (n_freq as f32); + for i in 0..spectrum_len { + p_re[i] *= delta_f; + p_im[i] *= delta_f; + } + + // Pack into interleaved [re0, im0, re1, im1, ...] for C++ caller + let mut out = vec![0.0f32; spectrum_len * 2]; + for b in 0..n_beams_us { + for f in 0..n_freq_us { + let idx = b * n_freq_us + f; + out[idx * 2] = p_re[idx]; + out[idx * 2 + 1] = p_im[idx]; + } + } + + let elapsed_ms = gpu_t0.elapsed().as_secs_f64() * 1000.0; + // Log on the very first frame and then every 50 frames. + if gpu_frame == 0 || gpu_frame % 50 == 49 { + let active_rays = (n_rays + std::cmp::max(1, ray_skips) - 1) / std::cmp::max(1, ray_skips); + eprintln!( + "[sonar_wgpu] GPU #{:<5} | {:6.1} ms | {} beams × {} rays × {} freq", + gpu_frame + 1, elapsed_ms, n_beams, active_rays, n_freq + ); + } + + let mut boxed = out.into_boxed_slice(); + let ptr = boxed.as_mut_ptr(); + std::mem::forget(boxed); + ptr +} + +#[no_mangle] +pub extern "C" fn sonar_wgpu_free(ptr: *mut f32, len: usize) { + if ptr.is_null() || len == 0 { + return; + } + unsafe { + let _ = Vec::from_raw_parts(ptr, len, len); + } +} diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs new file mode 100644 index 00000000..30d20e9a --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs @@ -0,0 +1,424 @@ +/// Adapters, device, queue setups + BUFFERS SETUP + PIPELINE SETUPS + PIPELINE COMPILE + GPU CONTEXT INIT all in pipeline.rs +/// + Shaders compiled + +use std::sync::OnceLock; + +/// GPU buffer lifetime: persistent allocation across frames via queue.write_buffer. +/// Buffers reallocated only when sonar dimensions change. +pub struct SonarBuffers { + pub n_beams: u32, + pub n_rays: u32, + pub n_freq: u32, + // Input data (written via queue.write_buffer each frame) + pub depth_buf: wgpu::Buffer, + pub normal_buf: wgpu::Buffer, + pub refl_buf: wgpu::Buffer, + pub window_buf: wgpu::Buffer, + pub bc_buf: wgpu::Buffer, + // Backscatter atomic i32 accumulators (zeroed via write_buffer each frame) + pub out_re_i32: wgpu::Buffer, + pub out_im_i32: wgpu::Buffer, + // Convert output / matmul input (f32) + pub mm_re_in: wgpu::Buffer, + pub mm_im_in: wgpu::Buffer, + // Matmul output / FFT input + pub mm_re_out: wgpu::Buffer, + pub mm_im_out: wgpu::Buffer, + // FFT in-place buffers + pub p_re_buf: wgpu::Buffer, + pub p_im_buf: wgpu::Buffer, + // Host-visible staging buffers for final readback + pub stg_re: wgpu::Buffer, + pub stg_im: wgpu::Buffer, +} + +impl SonarBuffers { + /// Buffer allocation for 4 pipeline stages: backscatter, convert, matmul, FFT. + /// Chain: backscatter→convert→matmul→FFT→readback. + pub fn new(device: &wgpu::Device, n_beams: u32, n_rays: u32, n_freq: u32) -> Self { + let depth_ray = (n_beams as usize) * (n_rays as usize); + let spectrum = (n_beams as usize) * (n_freq as usize); + let bc = (n_beams as usize) * (n_beams as usize); + let f32_bytes = |n: usize| (n * std::mem::size_of::()) as u64; + let i32_bytes = |n: usize| (n * std::mem::size_of::()) as u64; + + SonarBuffers { + n_beams, n_rays, n_freq, + depth_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_depth"), + size: f32_bytes(depth_ray), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + normal_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_normal"), + size: f32_bytes(depth_ray * 3), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + refl_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_refl"), + size: f32_bytes(depth_ray), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + window_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_window"), + size: f32_bytes(n_freq as usize), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + bc_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_bc"), + size: f32_bytes(bc), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + out_re_i32: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_re_i32"), + size: i32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + out_im_i32: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_im_i32"), + size: i32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + mm_re_in: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_mm_re_in"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }), + mm_im_in: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_mm_im_in"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }), + mm_re_out: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_mm_re_out"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + mm_im_out: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_mm_im_out"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }), + p_re_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_p_re"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + p_im_buf: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_p_im"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::STORAGE + | wgpu::BufferUsages::COPY_SRC + | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + stg_re: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_stg_re"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + stg_im: device.create_buffer(&wgpu::BufferDescriptor { + label: Some("pb_stg_im"), + size: f32_bytes(spectrum), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }), + } + } +} + +/// GPU compute context: device handle + compiled shader pipelines. +/// Four pipelines compiled once at init: backscatter, convert, matmul, FFT. +pub struct GpuContext { + pub device: wgpu::Device, // the GPU -> used to create everything + pub queue: wgpu::Queue, // the command queue -> used to submit work + + // Backscatter kernel: ray-surface interactions with Lambert scatter. + pub bs_pipeline: wgpu::ComputePipeline, // compiled backscatter shader + pub bs_bgl: wgpu::BindGroupLayout, // describes bs shader's buffer slots + + // i32→f32 conversion: fixed-point atomic backscatter output to float spectrum. + pub convert_pipeline: wgpu::ComputePipeline, + pub convert_bgl: wgpu::BindGroupLayout, + + // Beam correction GEMM: tiled matrix multiply for spectrum. + pub mm_pipeline: wgpu::ComputePipeline, + pub mm_bgl: wgpu::BindGroupLayout, + + // FFT kernel: power-of-2 only, N≤4096 (4096 is the shared memory limit). CPU fallback via Bluestein for arbitrary N. + pub fft_pipeline: wgpu::ComputePipeline, + pub fft_bgl: wgpu::BindGroupLayout, + + // SonarBuffers are None until first compute call when dimensions are known. + // Reallocated automatically if dimensions change between frames. + // Persistent GPU buffers across frames: CUDA cudaMalloc equivalent (allocated once, reused across ≥1 frames with same dimensions). + pub buffers: std::sync::Mutex>, +} + +static GPU_CONTEXT: OnceLock> = OnceLock::new(); + +/// Bind group layout entry: storage buffer descriptor. +fn make_storage_entry(binding: u32, read_only: bool) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only }, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +/// Uniform entry: shader parameter buffer. +fn make_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + } +} + +/// Bind group layout builder: defines buffer bindings for each shader. +fn build_bgls(device: &wgpu::Device) -> ( + wgpu::BindGroupLayout, + wgpu::BindGroupLayout, + wgpu::BindGroupLayout, + wgpu::BindGroupLayout, +) { + // Backscatter kernel params: uniform params, depth, normals, reflectivity, output accumulators, window. + let bs_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("bs_bgl"), + entries: &[ + make_uniform_entry(0), // (params) + make_storage_entry(1, true), // (depth) true = read_only + make_storage_entry(2, true), // (normal) + make_storage_entry(3, true), // (reflectivity) + make_storage_entry(4, false), // (out_re_i32) false = read_write + make_storage_entry(5, false), // (out_im_i32) + make_storage_entry(6, true), // (window) + ], + }); + + // Matmul (GEMM) kernel params: uniform params, beam_corrector, spectrum_in, spectrum_out. + let mm_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("mm_bgl"), + entries: &[ + make_uniform_entry(0), + make_storage_entry(1, true), + make_storage_entry(2, true), + make_storage_entry(3, false), + ], + }); + + // FFT kernel params: uniform params, spectrum_re, spectrum_im (in-place). + let fft_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("fft_bgl"), + entries: &[ + make_uniform_entry(0), + make_storage_entry(1, false), + make_storage_entry(2, false), + ], + }); + + // Convert kernel params: int32 input (re/im), float32 output (re/im). + let convert_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("convert_bgl"), + entries: &[ + make_storage_entry(0, true), + make_storage_entry(1, true), + make_storage_entry(2, false), + make_storage_entry(3, false), + ], + }); + + (bs_bgl, mm_bgl, fft_bgl, convert_bgl) +} + +/// Pipeline compilation: shader module compilation + pipeline layout binding (one-time init). +fn compile_pipeline( + device: &wgpu::Device, + label: &str, + wgsl: &str, + bgl: &wgpu::BindGroupLayout, +) -> wgpu::ComputePipeline { + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some(label), + source: wgpu::ShaderSource::Wgsl(wgsl.into()), + }); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(label), + bind_group_layouts: &[bgl], + push_constant_ranges: &[], + }); + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(label), + layout: Some(&layout), + module: &shader, + entry_point: "main", + compilation_options: wgpu::PipelineCompilationOptions::default(), + }) +} + +/// GPU context initialization: Vulkan adapter enumeration, device request. +fn init_gpu_context() -> Option { + // TODO: These environment variable overrides are NVIDIA/Vulkan-specific (Linux only). + // Replace with wgpu::Backends::all() so the adapter selection works automatically + // on any platform (Metal on macOS, DX12 on Windows, Vulkan on Linux) without + // needing manual ICD path configuration. + if std::env::var("VK_ICD_FILENAMES").is_err() { + unsafe { + std::env::set_var("VK_ICD_FILENAMES", "/usr/share/vulkan/icd.d/nvidia_icd.json"); + } + } + if std::env::var("VK_LAYER_PATH").is_err() { + unsafe { + std::env::set_var("DISABLE_LAYER_NV_optimus", "1"); + } + } + + // Catch panic to prevent GPU init failure from crashing Gazebo. + let result = std::panic::catch_unwind(|| { + // TODO: Replace wgpu::Backends::VULKAN with wgpu::Backends::all() to support + // Metal (macOS), DX12 (Windows), and other backends automatically. + // The explicit VULKAN flag means this will fail silently on non-Vulkan platforms. + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..Default::default() + }); + + // TODO: Same as above — enumerate_adapters(wgpu::Backends::all()) would pick up + // the best available backend on any platform instead of Vulkan-only. + let adapters: Vec<_> = instance + .enumerate_adapters(wgpu::Backends::VULKAN) + .into_iter() + .collect(); + + eprintln!("[sonar_wgpu] Vulkan adapters found: {}", adapters.len()); + for a in &adapters { + let info = a.get_info(); + eprintln!("[sonar_wgpu] {:?} -> {}", info.device_type, info.name); + } + + let adapter = adapters + .iter() + .find(|a| a.get_info().device_type == wgpu::DeviceType::DiscreteGpu) + .or_else(|| adapters.first())?; + + eprintln!("[sonar_wgpu] Selected adapter: {}", adapter.get_info().name); + let (device, queue) = pollster::block_on(adapter.request_device( + &wgpu::DeviceDescriptor { + label: Some("sonar_wgpu_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + }, + None, + )) + .map_err(|e| { + eprintln!("[sonar_wgpu] request_device failed: {e}"); + e + }) + .ok()?; + + // One-time shader compilation. + let t0 = std::time::Instant::now(); + let (bs_bgl, mm_bgl, fft_bgl, convert_bgl) = build_bgls(&device); + let bs_pipeline = compile_pipeline( + &device, "bs_pipeline", + include_str!("shaders/backscatter.wgsl"), &bs_bgl, + ); + let convert_pipeline = compile_pipeline( + &device, "convert_pipeline", + include_str!("shaders/convert.wgsl"), &convert_bgl, + ); + let mm_pipeline = compile_pipeline( + &device, "mm_pipeline", + include_str!("shaders/matmul.wgsl"), &mm_bgl, + ); + let fft_pipeline = compile_pipeline( + &device, "fft_pipeline", + include_str!("shaders/fft.wgsl"), &fft_bgl, + ); + eprintln!("[sonar_wgpu] GPU pipelines compiled in {:.0} ms -> ready.", t0.elapsed().as_millis()); + + Some(GpuContext { device, queue, bs_pipeline, bs_bgl, convert_pipeline, convert_bgl, mm_pipeline, mm_bgl, fft_pipeline, fft_bgl, buffers: std::sync::Mutex::new(None) }) + }); + + match result { + Ok(ctx) => ctx, + Err(_) => { + eprintln!("[sonar_wgpu] GPU init panicked; falling back to CPU path"); + None + } + } +} + +/// Global singleton: lazily initializes GPU context on first access. +/// Returns None if GPU unavailable; falls back to CPU implementation. +pub fn get_or_init() -> Option<&'static GpuContext> { + GPU_CONTEXT.get_or_init(init_gpu_context).as_ref() +} + + +/* +Data flow with buffer names: + + CPU INPUT DATA + │ + ▼ + depth_buf [n_beams × n_rays] f32 COPY_DST | STORAGE + normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE + refl_buf [n_beams × n_rays] f32 COPY_DST | STORAGE + window_buf [n_freq] f32 COPY_DST | STORAGE + bc_buf [n_beams × n_beams] f32 COPY_DST | STORAGE + │ + ▼ backscatter.wgsl (reads above, writes below) + │ + out_re_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← atomic accumulators + out_im_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← zeroed each frame + │ + ▼ convert.wgsl (i32 → f32) + │ + mm_re_in [n_beams × n_freq] f32 STORAGE + mm_im_in [n_beams × n_freq] f32 STORAGE + │ + ▼ matmul.wgsl (beam correction) + │ + mm_re_out [n_beams × n_freq] f32 STORAGE | COPY_SRC + mm_im_out [n_beams × n_freq] f32 STORAGE | COPY_SRC + │ + ▼ copied into FFT buffers + │ + p_re_buf [n_beams × n_freq] f32 STORAGE | COPY_SRC | COPY_DST + p_im_buf [n_beams × n_freq] f32 STORAGE | COPY_SRC | COPY_DST + │ + ▼ fft.wgsl (in-place FFT) + │ + ▼ copied to staging + │ + stg_re [n_beams × n_freq] f32 MAP_READ | COPY_DST + stg_im [n_beams × n_freq] f32 MAP_READ | COPY_DST + │ + ▼ CPU reads back final result + */ \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/backscatter.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/backscatter.wgsl new file mode 100644 index 00000000..a3a7a643 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/backscatter.wgsl @@ -0,0 +1,179 @@ +/// The physics kernel. + +struct Params { + n_beams: u32, + n_rays: u32, + n_freq: u32, + ray_skips: u32, + sound_speed: f32, + max_distance: f32, + source_level: f32, + attenuation: f32, + sensor_gain: f32, + bandwidth: f32, + seed_lo: u32, + seed_hi: u32, + frame_lo: u32, + frame_hi: u32, + area_scaler: f32, + h_fov: f32, + v_fov: f32, + _pad0: u32, + _pad1: u32, + _pad2: u32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var depth: array; +@group(0) @binding(2) var normals: array; +@group(0) @binding(3) var reflectivity: array; +@group(0) @binding(4) var out_re: array>; +@group(0) @binding(5) var out_im: array>; +@group(0) @binding(6) var window_vals: array; + +const PI: f32 = 3.14159265358979323846; +const SCALE: f32 = 1024.0; +const LIMIT: i32 = 2147483520i; // near i32 max + +// Philox4x32-10 RNG: WGPU/WGSL port of CUDA curand implementation. +// Based on Salmon et al., 2011, SC'11: "Random123: A Library of Counter-Based Random Number Generators". +// Based on https://github.com/DEShawResearch/random123/blob/main/include/Random123/philox.h +fn mulhilo32(a: u32, b: u32) -> vec2 { + let a_lo = a & 0xFFFFu; + let a_hi = a >> 16u; + let b_lo = b & 0xFFFFu; + let b_hi = b >> 16u; + let lo = a * b; + let p0 = a_lo * b_lo; + let p1 = a_hi * b_lo; + let p2 = a_lo * b_hi; + let p3 = a_hi * b_hi; + let mid = (p0 >> 16u) + (p1 & 0xFFFFu) + (p2 & 0xFFFFu); + let hi = p3 + (p1 >> 16u) + (p2 >> 16u) + (mid >> 16u); + return vec2(hi, lo); +} + +// Single Philox4x32 round. +fn philox_round(c: vec4, k: vec2) -> vec4 { + let r0 = mulhilo32(0xD2511F53u, c.x); + let r1 = mulhilo32(0xCD9E8D57u, c.z); + return vec4(r1.x ^ c.y ^ k.x, r1.y, r0.x ^ c.w ^ k.y, r0.y); +} + +// Full 10-round Philox4x32. +fn philox4x32_10(ctr: vec4, key: vec2) -> vec4 { + var c = ctr; + var k = key; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); k.x += 0x9E3779B9u; k.y += 0xBB67AE85u; + c = philox_round(c, k); + return c; +} + +// Convert raw u32 to uniform float (0,1]. +fn philox_uniform(x: u32) -> f32 { + return f32(x) * (1.0 / 4294967296.0) + (0.5 / 4294967296.0); +} + +// Generate 4 normal-distributed floats using Box-Muller transform. +// Counter: [frame_lo, frame_hi, subsequence, 0], Key: [seed_lo, seed_hi] +// Mirrors CUDA curand_init(seed, subsequence, offset=0) +fn philox_normal4(seed_lo: u32, seed_hi: u32, frame_lo: u32, frame_hi: u32, subsequence: u32) -> vec4 { + let raw = philox4x32_10(vec4(frame_lo, frame_hi, subsequence, 0u), vec2(seed_lo, seed_hi)); + let u1 = philox_uniform(raw.x); + let u2 = philox_uniform(raw.y); + let u3 = philox_uniform(raw.z); + let u4 = philox_uniform(raw.w); + let r1 = sqrt(-2.0 * log(u1)); + let t1 = 2.0 * PI * u2; + let r2 = sqrt(-2.0 * log(u3)); + let t2 = 2.0 * PI * u4; + return vec4(r1 * sin(t1), r1 * cos(t1), r2 * sin(t2), r2 * cos(t2)); +} + +@compute @workgroup_size(8, 8, 1) +fn main(@builtin(global_invocation_id) gid: vec3) { + let beam = gid.x; + let reduced_ray = gid.y; + if (beam >= params.n_beams) { + return; + } + + let ray_step = max(1u, params.ray_skips); + let actual_ray = reduced_ray * ray_step; + if (actual_ray >= params.n_rays) { + return; + } + + let dr = beam * params.n_rays + actual_ray; + let d = depth[dr]; + if (d <= 0.0 || d > params.max_distance) { + return; + } + + let nx = normals[dr * 3u + 0u]; + let ny = normals[dr * 3u + 1u]; + let nz = normals[dr * 3u + 2u]; + let refl = reflectivity[dr]; + + // Compute sensor-frame ray unit direction from (beam, actual_ray) pixel indices. + // Direction formula (sin(θ_b)cos(θ_r), sin(θ_r), cos(θ_b)cos(θ_r)) is unit-length by construction. + let beam_ang = params.h_fov * (f32(beam) / max(f32(params.n_beams) - 1.0, 1.0) - 0.5); + let ray_ang = params.v_fov * (f32(actual_ray) / max(f32(params.n_rays) - 1.0, 1.0) - 0.5); + let rd_x = sin(beam_ang) * cos(ray_ang); + let rd_y = sin(ray_ang); + let rd_z = cos(beam_ang) * cos(ray_ang); + // let cos_inc = abs(rd_x * nx + rd_y * ny + rd_z * nz); + let cos_inc = abs(nz); + let lambert_sqrt = sqrt(max(refl, 0.0)) * cos_inc; + + let source_term = sqrt(pow(10.0, params.source_level / 10.0)) * 1e-6; + let propagation = exp(-2.0 * params.attenuation * d) / (d * d); + let target_area_sqrt = sqrt(d * params.area_scaler); + // let amp_scale = source_term * propagation * lambert_sqrt * target_area_sqrt * params.sensor_gain; + let amp_scale = source_term * propagation * lambert_sqrt * target_area_sqrt; + + let subsequence = beam * params.n_rays + actual_ray; + let xi = philox_normal4(params.seed_lo, params.seed_hi, params.frame_lo, params.frame_hi, subsequence); + + let amp_re = (xi.x / sqrt(2.0)) * amp_scale; + let amp_im = (xi.y / sqrt(2.0)) * amp_scale; + + let delta_f = params.bandwidth / f32(params.n_freq); + let n_freq_f = f32(params.n_freq); + let is_even = (params.n_freq % 2u) == 0u; + + // Keep window buffer bound for interface stability. + _ = window_vals[0u]; + + // Per-frequency phase sweep + for (var f: u32 = 0u; f < params.n_freq; f = f + 1u) { + var freq: f32; + if (is_even) { + freq = delta_f * (-n_freq_f + 2.0 * (f32(f) + 1.0)) / 2.0; + } else { + freq = delta_f * (-(n_freq_f - 1.0) + 2.0 * (f32(f) + 1.0)) / 2.0; + } + + let kw = 2.0 * PI * freq / params.sound_speed; + let phase = 2.0 * d * kw; + let c = cos(phase); + let s = sin(phase); + + // let re_val = clamp((amp_re * c - amp_im * s) * SCALE, -LIMIT, LIMIT); + // let im_val = clamp((amp_re * s + amp_im * c) * SCALE, -LIMIT, LIMIT); + let re_val = clamp(i32(round((amp_re * c - amp_im * s) * SCALE)), -LIMIT, LIMIT); + let im_val = clamp(i32(round((amp_re * s + amp_im * c) * SCALE)), -LIMIT, LIMIT); + + let idx = beam * params.n_freq + f; + atomicAdd(&out_re[idx], i32(re_val)); + atomicAdd(&out_im[idx], i32(im_val)); + } +} diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl new file mode 100644 index 00000000..afa4ba6f --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl @@ -0,0 +1,15 @@ +/// Fixed-point atomic accumulation in backscatter.wgsl (SCALE=1024) allows us +/// to avoid floating-point atomics, which are not widely supported on GPUs. This shader +/// converts the accumulated i32 values back to f32 by dividing by SCALE=1024, precision 0.001. +@group(0) @binding(0) var in_re: array; +@group(0) @binding(1) var in_im: array; +@group(0) @binding(2) var out_re: array; +@group(0) @binding(3) var out_im: array; + +@compute @workgroup_size(64) +fn main(@builtin(global_invocation_id) gid: vec3) { + let i = gid.x; + if (i >= arrayLength(&in_re)) { return; } + out_re[i] = f32(in_re[i]) / 1024.0; + out_im[i] = f32(in_im[i]) / 1024.0; +} diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl new file mode 100644 index 00000000..7dec6e45 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl @@ -0,0 +1,86 @@ +// Cooley-Tukey FFT (power-of-2 sizes only, N≤4096), or CPU fallback for arbitrary N. + +struct Params { + n_beams: u32, + n_freq: u32, + log2_n: u32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var p_re: array; +@group(0) @binding(2) var p_im: array; + +// WGSL writes to shared memory (smem_re/im) -> fast scratchpad local to workgroup. +var smem_re: array; +var smem_im: array; + +// Bit reversal for FFT permutation. +fn bit_reverse(v: u32, bits: u32) -> u32 { + var x = v; + var y: u32 = 0u; + for (var i: u32 = 0u; i < bits; i = i + 1u) { + y = (y << 1u) | (x & 1u); + x = x >> 1u; + } + return y; +} + +// Cooley-Tukey FFT kernel with bit-reversal, butterfly stages, and writeback. +@compute @workgroup_size(256, 1, 1) +fn main( + @builtin(workgroup_id) wg: vec3, // (wg) -> which beam this workgroup handles + @builtin(local_invocation_id) lid: vec3 // (lid) -> which thread within the workgroup (0..255) +) { + let beam = wg.x; + if (beam >= params.n_beams || params.n_freq > 4096u) { + return; + } + + let n = params.n_freq; + let base = beam * n; + + // Bit-reversal permutation. + for (var i: u32 = lid.x; i < n; i = i + 256u) { + let j = bit_reverse(i, params.log2_n); + smem_re[j] = p_re[base + i]; + smem_im[j] = p_im[base + i]; + } + workgroupBarrier(); + + // Cooley-Tukey butterfly iterations. + for (var s: u32 = 0u; s < params.log2_n; s = s + 1u) { + let half = 1u << s; + let stride = half << 1u; + + for (var i: u32 = lid.x; i < n / 2u; i = i + 256u) { + let group = i / half; + let j = i % half; + let i0 = group * stride + j; + let i1 = i0 + half; + + // Twiddle factor: root-of-unity exp(-j*2*pi*k/N). + let angle = -2.0 * 3.14159265358979323846 * f32(j) / f32(stride); + let wr = cos(angle); + let wi = sin(angle); + + // Butterfly combine: complex multiply (u ± w*t). + let tr = smem_re[i1] * wr - smem_im[i1] * wi; + let ti = smem_re[i1] * wi + smem_im[i1] * wr; + let ur = smem_re[i0]; + let ui = smem_im[i0]; + + smem_re[i0] = ur + tr; + smem_im[i0] = ui + ti; + smem_re[i1] = ur - tr; + smem_im[i1] = ui - ti; + } + + workgroupBarrier(); + } + + // Writeback to global storage. + for (var i: u32 = lid.x; i < n; i = i + 256u) { + p_re[base + i] = smem_re[i]; + p_im[base + i] = smem_im[i]; + } +} diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl new file mode 100644 index 00000000..41f6e226 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl @@ -0,0 +1,71 @@ +// GPU GEMM: WGPU compute shader for beam correction via matrix multiply. +// Tiled approach: C = A*B normalized, with shared memory for cache optimization. + +struct Params { + n_beams: u32, + n_freq: u32, + beam_corrector_sum: f32, +}; + +@group(0) @binding(0) var params: Params; +@group(0) @binding(1) var a: array; // beam_corrector [nBeams*nBeams] +@group(0) @binding(2) var b: array; // spectrum input [nBeams*nFreq] +@group(0) @binding(3) var c: array; // output [nBeams*nFreq] + +// Shared memory tiles for GEMM optimization (tile size 16x16). +var tile_a: array, 16>; +var tile_b: array, 16>; + +// Tiled GEMM kernel: 16x16 workgroup, each thread computes one C[row,col] element. +@compute @workgroup_size(16, 16, 1) +fn main( + @builtin(global_invocation_id) gid: vec3, + @builtin(local_invocation_id) lid: vec3 +) { + let row = gid.y; + let col = gid.x; + if (row >= params.n_beams || col >= params.n_freq) { + return; + } + + // Accumulator: accumulates partial products across tiles. + var acc = 0.0; + let tiles = (params.n_beams + 15u) / 16u; + + // Loop over tiles of size 16: classic blocked GEMM. + // (k = beam index in the inner product) + for (var t: u32 = 0u; t < tiles; t = t + 1u) { + let k_a = t * 16u + lid.x; + let k_b = t * 16u + lid.y; + + // LOAD: load tile data into shared memory. + // Load A tile: each thread loads one A[row, k_a]. + if (k_a < params.n_beams) { + tile_a[lid.y][lid.x] = a[row * params.n_beams + k_a]; + } else { + tile_a[lid.y][lid.x] = 0.0; + } + + // Load B tile: each thread loads one B[k_b, col]. + if (k_b < params.n_beams) { + tile_b[lid.y][lid.x] = b[k_b * params.n_freq + col]; + } else { + tile_b[lid.y][lid.x] = 0.0; + } + + workgroupBarrier(); + + // COMPUTE: partial dot product. + // Accumulate tile product: inner loop over tile dimension. + for (var k: u32 = 0u; k < 16u; k = k + 1u) { + acc = acc + tile_a[lid.y][k] * tile_b[k][lid.x]; + } + + workgroupBarrier(); + } + + // WRITE: final result to global memory. + // Normalize output: divide by beam_corrector_sum. + let norm = max(params.beam_corrector_sum, 1e-12); + c[row * params.n_freq + col] = acc / norm; +} diff --git a/models/dave_sensor_models/description/blueview_p900/model.sdf b/models/dave_sensor_models/description/blueview_p900/model.sdf index dff61f43..01d96527 100644 --- a/models/dave_sensor_models/description/blueview_p900/model.sdf +++ b/models/dave_sensor_models/description/blueview_p900/model.sdf @@ -85,6 +85,8 @@ 12 900e3 29.9e3 + + 1500 220 10 From bbf0a1e59ce56aebf88a67818907deb451ca098b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 16 Mar 2026 20:44:52 +0000 Subject: [PATCH 02/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../multibeam_sonar/MultibeamSonarSensor.cc | 10 +++++----- gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml | 2 +- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 2 +- .../wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs | 8 ++++---- .../sonar_wgpu_rust/src/shaders/convert.wgsl | 4 ++-- .../sonar_wgpu_rust/src/shaders/matmul.wgsl | 2 +- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index 27841226..866248ff 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -419,7 +419,7 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo // NOTE: frameName is the TF frame name for published sonar messages (frame_id in ROS headers). // This is explicitly configured in SDF rather than derived from Gazebo's // internal sensor frame ID. Must match TF tree for RViz transforms to work correctly. - // Verify in SDF files that matches the expected TF frame name. + // Verify in SDF files that matches the expected TF frame name. this->frameName = sensorElement->Get("frameName", "forward_sonar_optical_link").first; gzmsg << "frameName: " << this->frameName << std::endl; @@ -1085,7 +1085,7 @@ cv::Mat MultibeamSonarSensor::Implementation::ComputeNormalImage(cv::Mat & depth // Calculate focal length using the actual depth image dimensions (cols) so // this function is safe to call from a background thread without accessing - // the shared pointMsg field. Avoid data race with pointMsg.width() which may + // the shared pointMsg field. Avoid data race with pointMsg.width() which may // be being written to by the render thread concurrently. double focal_length = (0.5 * depth.cols) / tan(0.5 * this->hFOV); images.at(2) = 1.0 / focal_length * depth; // for blue channel @@ -1158,7 +1158,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() } } // lock_ released -> render thread can proceed immediately. - + // NOTE: reflectivityImage is used here without a lock. // This is safe for now because only the compute thread writes to it // and the render thread only reads it. If the render thread ever starts modifying @@ -1303,10 +1303,10 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() // FEEDBACK_NEEDED/TODO: Using frameName (from SDF tag) as the ROS frame_id. // This is user-friendly but requires the SDF value to match an actual TF frame exactly. - // The old approach used frameId (Gazebo's internal sensor ID) which was more likely + // The old approach used frameId (Gazebo's internal sensor ID) which was more likely // to match the TF tree automatically. // If TF lookup errors appear in RViz, shall revert back to frameId. - + this->sonarRawDataMsg.header.frame_id = this->frameName; this->sonarRawDataMsg.header.stamp.sec = static_cast(capturedStamp.seconds()); diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml index 17f5eb07..1fdbb454 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml @@ -9,4 +9,4 @@ ament_cmake - + \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs index a737df3f..b64f8f72 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs @@ -328,7 +328,7 @@ pub extern "C" fn sonar_wgpu_compute( contents: bytemuck::bytes_of(&mm_params), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); - + // REMOVE THIS, UNNCESSARY // ---- Persistent data buffers: allocate once per dimension set, reuse every frame ---- let mut buf_guard = ctx.buffers.lock().unwrap(); diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs index 30d20e9a..1596a937 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs @@ -165,7 +165,7 @@ pub struct GpuContext { pub fft_bgl: wgpu::BindGroupLayout, // SonarBuffers are None until first compute call when dimensions are known. - // Reallocated automatically if dimensions change between frames. + // Reallocated automatically if dimensions change between frames. // Persistent GPU buffers across frames: CUDA cudaMalloc equivalent (allocated once, reused across ≥1 frames with same dimensions). pub buffers: std::sync::Mutex>, } @@ -215,7 +215,7 @@ fn build_bgls(device: &wgpu::Device) -> ( make_storage_entry(1, true), // (depth) true = read_only make_storage_entry(2, true), // (normal) make_storage_entry(3, true), // (reflectivity) - make_storage_entry(4, false), // (out_re_i32) false = read_write + make_storage_entry(4, false), // (out_re_i32) false = read_write make_storage_entry(5, false), // (out_im_i32) make_storage_entry(6, true), // (window) ], @@ -388,7 +388,7 @@ Data flow with buffer names: │ ▼ depth_buf [n_beams × n_rays] f32 COPY_DST | STORAGE - normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE + normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE refl_buf [n_beams × n_rays] f32 COPY_DST | STORAGE window_buf [n_freq] f32 COPY_DST | STORAGE bc_buf [n_beams × n_beams] f32 COPY_DST | STORAGE @@ -421,4 +421,4 @@ Data flow with buffer names: stg_im [n_beams × n_freq] f32 MAP_READ | COPY_DST │ ▼ CPU reads back final result - */ \ No newline at end of file + */ \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl index afa4ba6f..84f66af6 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/convert.wgsl @@ -1,5 +1,5 @@ -/// Fixed-point atomic accumulation in backscatter.wgsl (SCALE=1024) allows us -/// to avoid floating-point atomics, which are not widely supported on GPUs. This shader +/// Fixed-point atomic accumulation in backscatter.wgsl (SCALE=1024) allows us +/// to avoid floating-point atomics, which are not widely supported on GPUs. This shader /// converts the accumulated i32 values back to f32 by dividing by SCALE=1024, precision 0.001. @group(0) @binding(0) var in_re: array; @group(0) @binding(1) var in_im: array; diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl index 41f6e226..5df59bec 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl @@ -45,7 +45,7 @@ fn main( } else { tile_a[lid.y][lid.x] = 0.0; } - + // Load B tile: each thread loads one B[k_b, col]. if (k_b < params.n_beams) { tile_b[lid.y][lid.x] = b[k_b * params.n_freq + col]; From e812a99de28b3cc967e90a28742d4bd9699a1266 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Thu, 19 Mar 2026 00:43:05 +0530 Subject: [PATCH 03/18] add CUDA backend with runtime selection, rustfft migration Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/CMakeLists.txt | 69 ++++-- .../multibeam_sonar/MultibeamSonarSensor.cc | 17 +- .../multibeam_sonar/MultibeamSonarSensor.hh | 3 +- .../multibeam_sonar/sonar_compute_cpu.cc | 43 +++- .../multibeam_sonar/sonar_compute_cuda.cc | 119 +++++++++++ .../multibeam_sonar/sonar_compute_cuda.hh | 26 +++ .../multibeam_sonar/sonar_compute_wgpu.cc | 4 +- .../launch/multibeam_sonar_demo.launch.py | 2 +- .../multibeam_sonar_demo/scripts/plotdata.py | 17 +- .../multibeam_sonar_system/CMakeLists.txt | 50 +++-- .../multibeam_sonar_system/package.xml | 4 +- .../wgpu_vendor/CMakeLists.txt | 13 +- .../wgpu_vendor/sonar_wgpu_rust/Cargo.lock | 58 +++++ .../wgpu_vendor/sonar_wgpu_rust/Cargo.toml | 1 + .../wgpu_vendor/sonar_wgpu_rust/src/fft.rs | 202 +++--------------- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 188 +++------------- .../sonar_wgpu_rust/src/pipeline.rs | 107 ++++++---- .../sonar_wgpu_rust/src/shaders/matmul.wgsl | 12 +- 18 files changed, 483 insertions(+), 452 deletions(-) create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc create mode 100644 gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.hh diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index c02f0708..1849a906 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -9,6 +9,9 @@ set(CMAKE_SUPPRESS_DEVELOPER_WARNINGS TRUE CACHE BOOL cmake_policy(SET CMP0144 NEW) +set(CUDA_ARCHITECTURE "native" CACHE STRING "Target CUDA SM version(s), e.g. native or 70;80;89") +set(CMAKE_CUDA_ARCHITECTURES "${CUDA_ARCHITECTURE}") + project(multibeam_sonar) find_package(ament_cmake REQUIRED) @@ -25,6 +28,15 @@ find_package(OpenCV REQUIRED) find_package(marine_acoustic_msgs REQUIRED) find_package(cv_bridge REQUIRED) find_package(wgpu_vendor REQUIRED) +find_package(CUDAToolkit QUIET) + +if(CUDAToolkit_FOUND) + set(CMAKE_CUDA_COMPILER "${CUDAToolkit_NVCC_EXECUTABLE}" CACHE FILEPATH "CUDA compiler" FORCE) + enable_language(CUDA) + find_package(CUDA REQUIRED) + message(STATUS "CUDA ${CUDAToolkit_VERSION} found (SM ${CUDA_ARCHITECTURE}), enabling CUDA support.") + include_directories(${CUDA_INCLUDE_DIRS}) +endif() if(TARGET gz-rendering::ogre) add_definitions(-DWITH_OGRE) @@ -33,12 +45,27 @@ if(TARGET gz-rendering::ogre2) add_definitions(-DWITH_OGRE2) endif() -add_library(${PROJECT_NAME} SHARED +set(SONAR_SOURCES MultibeamSonarSensor.cc sonar_compute_cpu.cc sonar_compute_wgpu.cc + sonar_compute_cuda.cc ) +if(CUDAToolkit_FOUND) + list(APPEND SONAR_SOURCES sonar_calculation_cuda.cu) +endif() + +add_library(${PROJECT_NAME} SHARED ${SONAR_SOURCES}) + +if(CUDAToolkit_FOUND) + set_target_properties(${PROJECT_NAME} + PROPERTIES + CUDA_SEPARABLE_COMPILATION ON + ) + target_compile_definitions(${PROJECT_NAME} PRIVATE DAVE_HAS_CUDA_BACKEND=1) +endif() + target_include_directories(${PROJECT_NAME} PUBLIC ${gz-sensors_INCLUDE_DIRS} ${gz-rendering_INCLUDE_DIRS} @@ -53,12 +80,6 @@ target_include_directories(${PROJECT_NAME} PUBLIC ${cv_bridge_INCLUDE_DIRS} ) -if(DEFINED ENV{ROS_DISTRO} AND EXISTS "/opt/ros/$ENV{ROS_DISTRO}/include/cv_bridge") - target_include_directories(${PROJECT_NAME} PUBLIC "/opt/ros/$ENV{ROS_DISTRO}/include/cv_bridge") -elseif(EXISTS "/opt/ros/rolling/include/cv_bridge") - target_include_directories(${PROJECT_NAME} PUBLIC "/opt/ros/rolling/include/cv_bridge") -endif() - target_link_libraries(${PROJECT_NAME} gz-sim::gz-sim gz-sensors::gz-sensors @@ -72,6 +93,32 @@ target_link_libraries(${PROJECT_NAME} ${marine_acoustic_msgs_LIBRARIES} ) +if(TARGET cv_bridge::cv_bridge) + target_link_libraries(${PROJECT_NAME} cv_bridge::cv_bridge) +elseif(TARGET cv_bridge) + target_link_libraries(${PROJECT_NAME} cv_bridge) +elseif(cv_bridge_LIBRARIES) + target_link_libraries(${PROJECT_NAME} ${cv_bridge_LIBRARIES}) +endif() + +if(CUDAToolkit_FOUND) + find_library(CUBLAS_LIB cublas + HINTS + ${CUDAToolkit_LIBRARY_DIR} + /usr/local/cuda/lib64 + /usr/lib/x86_64-linux-gnu + /usr/lib + ) + if(NOT CUBLAS_LIB) + message(FATAL_ERROR "cuBLAS not found") + endif() + target_link_libraries(${PROJECT_NAME} + ${CUDA_LIBRARIES} + ${CUDA_CUFFT_LIBRARIES} + ${CUBLAS_LIB} + ) +endif() + if(TARGET wgpu::wgpu_vendor) target_link_libraries(${PROJECT_NAME} wgpu::wgpu_vendor) elseif(TARGET wgpu_vendor) @@ -89,14 +136,6 @@ if(DEFINED wgpu_vendor_DIR) endif() endif() -if(TARGET cv_bridge::cv_bridge) - target_link_libraries(${PROJECT_NAME} cv_bridge::cv_bridge) -elseif(TARGET cv_bridge) - target_link_libraries(${PROJECT_NAME} cv_bridge) -else() - target_link_libraries(${PROJECT_NAME} ${cv_bridge_LIBRARIES}) -endif() - if(TARGET gz-sensors::gz-sensors-rendering) target_link_libraries(${PROJECT_NAME} gz-sensors::gz-sensors-rendering) else() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index 866248ff..a5e49e57 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -427,10 +427,12 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo if (const char * backendEnv = std::getenv("DAVE_SONAR_COMPUTE_BACKEND")) { this->requestedBackend = backendEnv; + gzmsg << "DAVE_SONAR_COMPUTE_BACKEND=" << this->requestedBackend << std::endl; } else { this->requestedBackend = "auto"; + gzmsg << "DAVE_SONAR_COMPUTE_BACKEND not set. Using default backend=auto" << std::endl; } this->computeBackend = CreateComputeBackend(this->requestedBackend); @@ -1107,14 +1109,19 @@ cv::Mat MultibeamSonarSensor::Implementation::ComputeNormalImage(cv::Mat & depth } // Precalculation of corrector sonar calculation -void MultibeamSonarSensor::Implementation::ComputeCorrector() +void MultibeamSonarSensor::Implementation::ComputeCorrector(int _snapshotWidth, int _nBeams) { - double hPixelSize = this->hFOV / (this->pointMsg.width() - 1); + if (_snapshotWidth <= 1 || _nBeams <= 0) + { + return; + } + + double hPixelSize = this->hFOV / (_snapshotWidth - 1); // Beam culling correction precalculation - for (size_t beam = 0; beam < this->nBeams; beam++) + for (int beam = 0; beam < _nBeams; beam++) { - for (size_t beam_other = 0; beam_other < this->nBeams; beam_other++) + for (int beam_other = 0; beam_other < _nBeams; beam_other++) { float azimuthBeamPattern = unnormalized_sinc( M_PI * 0.884 / hPixelSize * @@ -1148,7 +1155,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() if (this->beamCorrectorSum == 0) { - ComputeCorrector(); + ComputeCorrector(depthSnapshot.cols, this->nBeams); } if (this->reflectivityImage.rows == 0) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh index 7e3b65d2..f8eb8539 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.hh @@ -164,6 +164,7 @@ private: std::string sonarImageRawTopicName; std::string sonarImageTopicName; std::string frameName; + std::string frameId; // for non-optical frame id from sensor // Ray parameters int raySkips; @@ -216,7 +217,7 @@ private: const std::string & /*_format*/); void ComputeSonarImage(); cv::Mat ComputeNormalImage(cv::Mat & depth); - void ComputeCorrector(); + void ComputeCorrector(int _snapshotWidth, int _nBeams); // Connections gz::common::ConnectionPtr rayConnection; diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc index b997c187..5290883c 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc @@ -3,6 +3,7 @@ #include #include #include +#include // NOTE: This file serves two responsibilities -- it implements the CPU compute // backend (CpuComputeBackend) AND hosts the CreateComputeBackend() factory @@ -14,6 +15,7 @@ // so that the CPU and WGPU backends are fully decoupled and the factory is the // only translation unit that needs to know about both. #include "sonar_compute_wgpu.hh" +#include "sonar_compute_cuda.hh" namespace gz { @@ -146,6 +148,8 @@ class CpuComputeBackend : public ComputeBackend std::unique_ptr CreateComputeBackend(const std::string & requestedBackend) { + std::cerr << "[sonar_compute_factory] CreateComputeBackend called with: " << requestedBackend << std::endl; + std::string backend = requestedBackend; std::transform( backend.begin(), backend.end(), backend.begin(), @@ -153,25 +157,62 @@ std::unique_ptr CreateComputeBackend(const std::string & request if (backend == "wgpu") { + std::cerr << "[sonar_compute_factory] Attempting WgpuComputeBackend initialization..." << std::endl; auto wgpuBackend = std::make_unique(); SonarComputeInput probe; if (wgpuBackend->Initialize(probe)) { + std::cerr << "[sonar_compute_factory] SUCCESS: WgpuComputeBackend initialized!" << std::endl; return wgpuBackend; } + std::cerr << "[sonar_compute_factory] WGPU backend requested but FAILED to initialize." + << std::endl; return nullptr; } + if (backend == "cuda") + { + std::cerr << "[sonar_compute_factory] Attempting CudaComputeBackend initialization..." << std::endl; + auto cudaBackend = std::make_unique(); + SonarComputeInput probe; + if (cudaBackend->Initialize(probe)) + { + std::cerr << "[sonar_compute_factory] SUCCESS: CudaComputeBackend initialized!" << std::endl; + return cudaBackend; + } + std::cerr << "[sonar_compute_factory] CUDA backend requested but failed to initialize." << std::endl; + return nullptr; + } + + if (backend == "cpu") + { + std::cerr << "[sonar_compute_factory] Creating CPU backend" << std::endl; + return std::make_unique(); + } + if (backend == "auto") { - auto wgpuBackend = std::make_unique(); + std::cerr << "[sonar_compute_factory] Auto-selecting best available backend..." << std::endl; SonarComputeInput probe; + auto cudaBackend = std::make_unique(); + if (cudaBackend->Initialize(probe)) + { + std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected CUDA backend" << std::endl; + return cudaBackend; + } + + auto wgpuBackend = std::make_unique(); if (wgpuBackend->Initialize(probe)) { + std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected WGPU backend (CUDA unavailable)" + << std::endl; return wgpuBackend; } + std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected CPU backend (both CUDA and WGPU unavailable)" + << std::endl; } + std::cerr << "[sonar_compute_factory] Creating default CPU backend" << std::endl; return std::make_unique(); } diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc new file mode 100644 index 00000000..8aea552e --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc @@ -0,0 +1,119 @@ +#include "sonar_compute_cuda.hh" + +#include +#include + +#ifdef DAVE_HAS_CUDA_BACKEND +#include +#include "sonar_calculation_cuda.cuh" +#endif + +namespace gz +{ +namespace sensors +{ + +CudaComputeBackend::~CudaComputeBackend() +{ +#ifdef DAVE_HAS_CUDA_BACKEND + if (this->initialized) + { + NpsGazeboSonar::free_cuda_memory(); + } +#endif +} + +const char * CudaComputeBackend::Name() const { return "cuda"; } + +bool CudaComputeBackend::Initialize(const SonarComputeInput &) +{ +#ifdef DAVE_HAS_CUDA_BACKEND + int deviceCount = 0; + const cudaError_t err = cudaGetDeviceCount(&deviceCount); + if (err != cudaSuccess || deviceCount <= 0) + { + return false; + } + + this->initialized = true; + return true; +#else + return false; +#endif +} + +bool CudaComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOutput & output) +{ +#ifdef DAVE_HAS_CUDA_BACKEND + if (!this->initialized) + { + return false; + } + + if (!input.depthImage || !input.normalImage || !input.reflectivityImage || + !input.window || !input.beamCorrector || input.elevation_angles.empty()) + { + return false; + } + + auto start = std::chrono::high_resolution_clock::now(); + + const double hPixelSize = input.hFOV / static_cast(input.nBeams - 1); + const double vPixelSize = input.vFOV / static_cast(input.nRays - 1); + + const NpsGazeboSonar::CArray2D beams = NpsGazeboSonar::sonar_calculation_wrapper( + *input.depthImage, + *input.normalImage, + hPixelSize, + vPixelSize, + input.hFOV, + input.vFOV, + hPixelSize, + input.vFOV / 180.0 * M_PI, + hPixelSize, + const_cast(input.elevation_angles.data()), + vPixelSize * (input.raySkips + 1), + input.soundSpeed, + input.maxDistance, + input.sourceLevel, + input.nBeams, + input.nRays, + input.raySkips, + input.sonarFreq, + input.bandwidth, + input.nFreq, + *input.reflectivityImage, + input.attenuation, + const_cast(input.window), + input.beamCorrector, + input.beamCorrectorSum, + false, + input.blazingFlag); + + output.nBeams = input.nBeams; + output.nFreq = input.nFreq; + output.beamSpectrum.assign( + static_cast(input.nBeams) * static_cast(input.nFreq), + std::complex(0.0f, 0.0f)); + + for (int beam = 0; beam < input.nBeams; ++beam) + { + for (int freq = 0; freq < input.nFreq; ++freq) + { + output.At(beam, freq) = beams[beam][freq]; + } + } + + auto stop = std::chrono::high_resolution_clock::now(); + output.computeMicros = + std::chrono::duration_cast(stop - start).count(); + return true; +#else + static_cast(input); + static_cast(output); + return false; +#endif +} + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.hh new file mode 100644 index 00000000..7dfdb47f --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.hh @@ -0,0 +1,26 @@ +#pragma once + +#include "sonar_compute_backend.hh" + +namespace gz +{ +namespace sensors +{ + +class CudaComputeBackend : public ComputeBackend +{ +public: + ~CudaComputeBackend() override; + + const char * Name() const override; + + bool Initialize(const SonarComputeInput & prototype) override; + + bool Compute(const SonarComputeInput & input, SonarComputeOutput & output) override; + +private: + bool initialized{false}; +}; + +} // namespace sensors +} // namespace gz diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc index 698b1fd1..59cd9ed8 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc @@ -68,6 +68,8 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu { if (!this->cpuFallback) { + std::cerr << "[sonar_wgpu] GPU unavailable on first compute -> creating CPU fallback backend" + << std::endl; this->cpuFallback = CreateComputeBackend("cpu"); if (this->cpuFallback) { @@ -112,7 +114,7 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu const float hPixelSize = static_cast(input.hFOV) / std::max(1, nBeams - 1); const float vPixelSize = static_cast(input.vFOV) / std::max(1, nRays - 1); - const int raySkipsFactor = std::max(1, input.raySkips); + const int raySkipsFactor = input.raySkips + 1; const float area_scaler = hPixelSize * vPixelSize * raySkipsFactor; // This transposes from OpenCV's [row=ray][col=beam] to [beam][ray] for WGPU. diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py index c8f1aadc..c4bf519d 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/launch/multibeam_sonar_demo.launch.py @@ -62,7 +62,7 @@ def generate_launch_description(): DeclareLaunchArgument( "compute_backend", default_value="auto", - description="Sonar backend selection: auto|wgpu|cpu", + description="Sonar backend selection: auto|wgpu|cuda|cpu", ), DeclareLaunchArgument("rviz", default_value="true", description="Open RViz."), SetEnvironmentVariable("DAVE_SONAR_COMPUTE_BACKEND", compute_backend), diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py index 675d62b5..f3ad4248 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/scripts/plotdata.py @@ -12,21 +12,8 @@ maxRange = 5 xPlotRange = 10 yPlotRange = xPlotRange * np.cos(45 * np.pi / 180) -source_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "..")) -inferred_root = source_root -candidates = [ - os.environ.get("DAVE_SOURCE_ROOT", ""), - os.getcwd(), - inferred_root, - os.path.join(inferred_root, "src", "dave"), -] -source_root = inferred_root -for candidate in candidates: - if candidate and os.path.exists(os.path.join(candidate, "models", "dave_worlds", "worlds")): - source_root = candidate - break - -results_dir = os.path.join(source_root, "results") + +results_dir = os.path.join(os.path.dirname(__file__), "..", "..", "..", "..", "results") os.makedirs(results_dir, exist_ok=True) filename = os.path.join(results_dir, "SonarRawData_000001.csv") diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt index cf18b197..3787c76d 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt @@ -2,23 +2,26 @@ cmake_minimum_required(VERSION 3.11.0 FATAL_ERROR) project(multibeam_sonar_system) find_package(ament_cmake REQUIRED) -find_package(gz-common REQUIRED COMPONENTS profiler) -find_package(gz-plugin REQUIRED COMPONENTS register) -find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) -find_package(gz-sim REQUIRED) -find_package(gz-sensors REQUIRED) - -if(TARGET gz-rendering::ogre) - add_definitions(-DWITH_OGRE) -endif() +find_package(CUDAToolkit QUIET) -if(TARGET gz-rendering::ogre2) - add_definitions(-DWITH_OGRE2) -endif() +if(CUDAToolkit_FOUND) + find_package(gz-cmake REQUIRED) + find_package(gz-common REQUIRED COMPONENTS profiler) + find_package(gz-plugin REQUIRED COMPONENTS register) + find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) + find_package(gz-sim REQUIRED) + find_package(gz-sensors REQUIRED) + + if(TARGET gz-rendering::ogre) + add_definitions(-DWITH_OGRE) + endif() -add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) + if(TARGET gz-rendering::ogre2) + add_definitions(-DWITH_OGRE2) + endif() + + add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) -if(TARGET multibeam_sonar) add_library(${PROJECT_NAME} SHARED MultibeamSonarSystem.cc) target_link_libraries(${PROJECT_NAME} gz-common::gz-common @@ -27,12 +30,23 @@ if(TARGET multibeam_sonar) gz-sensors::gz-sensors multibeam_sonar ) - target_include_directories(${PROJECT_NAME} PUBLIC ../multibeam_sonar) + target_include_directories(${PROJECT_NAME} + PUBLIC + ../multibeam_sonar + ) + + # Install targets + install(TARGETS ${PROJECT_NAME} + DESTINATION lib/${PROJECT_NAME} + ) + + # Environment hooks + ament_environment_hooks( + "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in" + ) - install(TARGETS ${PROJECT_NAME} DESTINATION lib/${PROJECT_NAME}) - ament_environment_hooks("${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") else() - message(STATUS "multibeam_sonar target unavailable: Skipping multibeam_sonar_system") + message(STATUS "CUDA Toolkit not found: Skipping CUDA-specific targets") endif() ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml index 6f921a21..352de2b5 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml @@ -8,10 +8,8 @@ Gaurav kumar Apache 2.0 geometry_msgs - ament_cmake - multibeam_sonar + ament_cmake ament_lint_auto - ament_lint_common ament_cmake diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt index 511b06e1..3a28cf79 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt @@ -2,7 +2,9 @@ cmake_minimum_required(VERSION 3.11) project(wgpu_vendor) find_package(ament_cmake REQUIRED) -find_package(Vulkan REQUIRED) + +# WGPU can use Metal, DirectX, or Vulkan +find_package(Vulkan QUIET) find_program(CARGO_EXECUTABLE cargo REQUIRED) @@ -38,7 +40,10 @@ add_dependencies(sonar_wgpu_rust_static sonar_wgpu_rust) add_library(wgpu_vendor INTERFACE) add_dependencies(wgpu_vendor sonar_wgpu_rust_static) -target_link_libraries(wgpu_vendor INTERFACE sonar_wgpu_rust_static dl pthread m Vulkan::Vulkan) +target_link_libraries(wgpu_vendor INTERFACE sonar_wgpu_rust_static dl pthread m) +if(Vulkan_FOUND) + target_link_libraries(wgpu_vendor INTERFACE Vulkan::Vulkan) +endif() add_library(wgpu::wgpu_vendor ALIAS wgpu_vendor) @@ -51,5 +56,7 @@ install(EXPORT wgpu_vendorTargets DESTINATION share/${PROJECT_NAME}/cmake) ament_export_targets(wgpu_vendorTargets HAS_LIBRARY_TARGET) -ament_export_dependencies(Vulkan) +if(Vulkan_FOUND) + ament_export_dependencies(Vulkan) +endif() ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock index 9e8ac211..9e3c585d 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock @@ -549,6 +549,24 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -626,6 +644,15 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" +[[package]] +name = "primal-check" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" +dependencies = [ + "num-integer", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -703,6 +730,20 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" +[[package]] +name = "rustfft" +version = "6.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" +dependencies = [ + "num-complex", + "num-integer", + "num-traits", + "primal-check", + "strength_reduce", + "transpose", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -743,6 +784,7 @@ dependencies = [ "bytemuck", "pollster", "rayon", + "rustfft", "wgpu", ] @@ -761,6 +803,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strength_reduce" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" + [[package]] name = "syn" version = "1.0.109" @@ -812,6 +860,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "transpose" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" +dependencies = [ + "num-integer", + "strength_reduce", +] + [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml index e09be284..6d8fc301 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml @@ -11,6 +11,7 @@ wgpu = { version = "0.20" } bytemuck = { version = "1", features = ["derive"] } pollster = "0.3" rayon = "1" +rustfft = "6" [profile.release] opt-level = 3 diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs index 1bfe5103..ad40dfc8 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs @@ -1,164 +1,29 @@ -// Cooley-Tukey FFT and Bluestein's chirp-Z transform for non-power-of-two sizes +// CPU FFT using rustfft for both power-of-two and arbitrary lengths. -/// CPU FFT: WGPU port of CUDA cuFFT. -/// Power-of-2 N: Cooley-Tukey O(N log N). Arbitrary N: Bluestein chirp-Z O(N log M). -/// CPU used for validation and CUDA non-power-of-2 fallback (GPU shader: power-of-2 only, N <= 4096). use crate::ComplexF; use rayon::prelude::*; +use rustfft::{num_complex::Complex, FftPlanner}; -fn is_power_of_two(x: usize) -> bool { - x != 0 && (x & (x - 1)) == 0 -} - -fn bit_reverse(mut x: usize, bits: usize) -> usize { - let mut y = 0usize; - for _ in 0..bits { - y = (y << 1) | (x & 1); - x >>= 1; - } - y -} - -/// Cooley-Tukey FFT: CUDA cuFFT Cooley-Tukey algorithm (CPU reference). -/// Requires N = power of 2. Use bluestein_fft() for arbitrary N (CUDA extension). -fn fft_in_place(data: &mut [ComplexF]) { - let n = data.len(); - if n <= 1 { - return; - } - - let bits = (n as f32).log2() as usize; - for i in 0..n { - let j = bit_reverse(i, bits); - if j > i { - data.swap(i, j); - } - } - - let mut len = 2usize; - while len <= n { - let ang = -2.0f32 * std::f32::consts::PI / len as f32; - let wlen_re = ang.cos(); - let wlen_im = ang.sin(); - - let mut i = 0usize; - while i < n { - let mut w_re = 1.0f32; - let mut w_im = 0.0f32; - let half = len / 2; - for j in 0..half { - let u = data[i + j]; - let t = data[i + j + half]; - - let v_re = t.re * w_re - t.im * w_im; - let v_im = t.re * w_im + t.im * w_re; - - data[i + j] = ComplexF { - re: u.re + v_re, - im: u.im + v_im, - }; - data[i + j + half] = ComplexF { - re: u.re - v_re, - im: u.im - v_im, - }; - - let nw_re = w_re * wlen_re - w_im * wlen_im; - let nw_im = w_re * wlen_im + w_im * wlen_re; - w_re = nw_re; - w_im = nw_im; - } - i += len; - } - len <<= 1; - } -} - -/// Inverse FFT: Matches CUDA cuFFT conjugate-symmetry trick. IFFT(x) = conj(FFT(conj(x))) / N. -/// Requires power-of-2 N matching forward FFT (CUDA cufftExecC2C standard). -fn ifft_in_place(data: &mut [ComplexF]) { - let n = data.len(); - for x in data.iter_mut() { - x.im = -x.im; - } - fft_in_place(data); - let inv = 1.0f32 / n as f32; - for x in data.iter_mut() { - x.re *= inv; - x.im = -x.im * inv; - } -} - -/// Bluestein chirp-Z: Non-CUDA extension for arbitrary N DFT. O(N log M) where M = smallest 2^k >= 2N-1. -/// ~30-50x faster than naive O(N²) DFT. CUDA cuFFT only supports power-of-2; this is CPU addon. -fn bluestein_fft(data: &mut [ComplexF]) { - let n = data.len(); - if n <= 1 { - return; - } - - // M = smallest power-of-two >= 2*n - 1 (ensures linear convolution fits) - let mut m = 1usize; - while m < 2 * n - 1 { - m <<= 1; - } - - // Chirp sequence: w[k] = exp(-j*pi*k^2/n) - // stored as (cos, sin) where the complex value is (cos, -sin) = cos - j*sin - let chirp: Vec<(f32, f32)> = (0..n) - .map(|k| { - let theta = std::f32::consts::PI * (k * k) as f32 / n as f32; - (theta.cos(), theta.sin()) // (cos θ, sin θ) so chirp = cos - j·sin - }) - .collect(); - - // a[k] = data[k] * chirp[k] = data[k] * exp(-j*pi*k^2/n), zero-padded to M - let mut a = vec![ComplexF::default(); m]; - for k in 0..n { - let (c, s) = chirp[k]; // chirp[k] = (c, -s) as complex - a[k].re = data[k].re * c + data[k].im * s; // Re[(a+jb)(c-js)] = ac+bs - a[k].im = data[k].im * c - data[k].re * s; // Im[(a+jb)(c-js)] = bc-as - } - - // h[k] = conj(chirp[k]) = exp(+j*pi*k^2/n), with wrap-around: h[M-k] = h[k] - let mut h = vec![ComplexF::default(); m]; - for k in 0..n { - let (c, s) = chirp[k]; - h[k] = ComplexF { re: c, im: s }; // conj(chirp[k]) = cos + j*sin - if k > 0 { - h[m - k] = h[k]; // symmetric fill for circular convolution - } - } - - // Circular convolution via FFT: Y = IFFT(FFT(a) * FFT(h)) - fft_in_place(&mut a); - fft_in_place(&mut h); - for i in 0..m { - let re = a[i].re * h[i].re - a[i].im * h[i].im; - let im = a[i].re * h[i].im + a[i].im * h[i].re; - a[i] = ComplexF { re, im }; - } - ifft_in_place(&mut a); - - // X[k] = a[k] * chirp[k] = a[k] * exp(-j*pi*k^2/n) - for k in 0..n { - let (c, s) = chirp[k]; - data[k].re = a[k].re * c + a[k].im * s; - data[k].im = a[k].im * c - a[k].re * s; - } -} - -/// Batched FFT: CPU dispatcher matching CUDA cuFFT batching. Routes to GPU (power-of-2 && <= 4096) or CPU fallback. +/// Batched FFT preserving the existing public API used by lib.rs. pub fn fft_batched(input: &[ComplexF], n_beams: usize, n_freq: usize) -> Vec { - // Collect into per-beam rows, transform in parallel, then flatten back. let mut rows: Vec> = (0..n_beams) .map(|b| input[b * n_freq..(b + 1) * n_freq].to_vec()) .collect(); + let mut planner = FftPlanner::::new(); + let fft = planner.plan_fft_forward(n_freq); + rows.par_iter_mut().for_each(|row| { - if is_power_of_two(row.len()) { - fft_in_place(row); - } else { - bluestein_fft(row); + let mut rustfft_row: Vec> = row + .iter() + .map(|x| Complex:: { re: x.re, im: x.im }) + .collect(); + + fft.process(&mut rustfft_row); + + for (dst, src) in row.iter_mut().zip(rustfft_row.iter()) { + dst.re = src.re; + dst.im = src.im; } }); @@ -196,14 +61,14 @@ mod tests { } #[test] - fn bluestein_matches_naive_dft_n3() { - let mut x_b = vec![ + fn rustfft_matches_naive_dft_n3() { + let x = vec![ ComplexF { re: 1.0, im: 0.0 }, ComplexF { re: 2.0, im: -1.0 }, ComplexF { re: 0.5, im: 3.0 }, ]; - let mut x_d = x_b.clone(); - bluestein_fft(&mut x_b); + let x_b = fft_batched(&x, 1, 3); + let mut x_d = x.clone(); naive_dft(&mut x_d); for (b, d) in x_b.iter().zip(x_d.iter()) { assert!(nearly_eq(b.re, d.re), "re: {} vs {}", b.re, d.re); @@ -212,7 +77,7 @@ mod tests { } #[test] - fn bluestein_matches_naive_dft_n399() { + fn rustfft_matches_naive_dft_n399() { use std::f32::consts::PI; let n = 399usize; // Construct a chirp-like test signal @@ -222,9 +87,8 @@ mod tests { im: ((k as f32) * PI / 75.0).sin(), }) .collect(); - let mut x_b = input.clone(); + let x_b = fft_batched(&input, 1, n); let mut x_d = input.clone(); - bluestein_fft(&mut x_b); naive_dft(&mut x_d); // Compare first 5 and last 5 bins for &i in &[0, 1, 2, 3, 4, 394, 395, 396, 397, 398] { @@ -236,33 +100,15 @@ mod tests { } #[test] - fn bluestein_impulse_gives_constant_spectrum() { + fn rustfft_impulse_gives_constant_spectrum() { // DFT of [1, 0, 0, ..., 0] = [1, 1, 1, ..., 1] let n = 399usize; let mut x = vec![ComplexF::default(); n]; x[0].re = 1.0; - bluestein_fft(&mut x); + let x = fft_batched(&x, 1, n); for (i, v) in x.iter().enumerate() { assert!(nearly_eq(v.re, 1.0), "bin {i} re = {} (expected 1)", v.re); assert!(nearly_eq(v.im, 0.0), "bin {i} im = {} (expected 0)", v.im); } } } - - -/* - -X[k] = Σ x[n] · e^(-j·2π·kn/N) - - = Σ x[n] · e^(-jπ(k² + n² - (k-n)²)/N) - - = e^(-jπk²/N) · Σ [x[n] · e^(-jπn²/N)] · e^(+jπ(k-n)²/N) - ─────────── ───────────────────── ──────────────── - post-chirp a[n] (pre-chirped) h[k-n] (filter) - -So the full Bluestein algorithm is: - 1. a[n] = x[n] · e^(-jπn²/N) premultiply by chirp - 2. Y[k] = (a ★ h)[k] convolve with conjugate chirp filter - 3. X[k] = Y[k] · e^(-jπk²/N) postmultiply by chirp - -*/ \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs index b64f8f72..9b4e5c81 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs @@ -59,165 +59,6 @@ struct FftParams { log2_n: u32, } -// REMOVE THIS, UNNCESSARY -/// Single-buffer i32 readback kept for CPU-fallback / test paths. -#[allow(dead_code)] -fn readback_i32( - device: &wgpu::Device, - queue: &wgpu::Queue, - src: &wgpu::Buffer, - len: usize, -) -> Option> { - let bytes = (len * std::mem::size_of::()) as u64; - let staging = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("readback_i32"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("readback_i32_encoder"), - }); - encoder.copy_buffer_to_buffer(src, 0, &staging, 0, bytes); - queue.submit(std::iter::once(encoder.finish())); - device.poll(wgpu::Maintain::Wait); - let slice = staging.slice(..); - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::Maintain::Wait); - let data = slice.get_mapped_range(); - let out = bytemuck::cast_slice::(&data).to_vec(); - drop(data); - staging.unmap(); - Some(out) -} - -#[allow(dead_code)] -/// Read two i32 buffers back to CPU in **one** encoder+submit+poll pair. -/// Returns (re_vec, im_vec). -fn readback_i32_pair( - device: &wgpu::Device, - queue: &wgpu::Queue, - src_re: &wgpu::Buffer, - src_im: &wgpu::Buffer, - len: usize, -) -> Option<(Vec, Vec)> { - let bytes = (len * std::mem::size_of::()) as u64; - let stg_re = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("stg_re"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let stg_im = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("stg_im"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("readback_i32_pair"), - }); - enc.copy_buffer_to_buffer(src_re, 0, &stg_re, 0, bytes); - enc.copy_buffer_to_buffer(src_im, 0, &stg_im, 0, bytes); - queue.submit(std::iter::once(enc.finish())); - device.poll(wgpu::Maintain::Wait); - - stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::Maintain::Wait); - - let re_data = stg_re.slice(..).get_mapped_range(); - let im_data = stg_im.slice(..).get_mapped_range(); - let re = bytemuck::cast_slice::(&re_data).to_vec(); - let im = bytemuck::cast_slice::(&im_data).to_vec(); - drop(re_data); - drop(im_data); - stg_re.unmap(); - stg_im.unmap(); - Some((re, im)) -} - -// REMOVE THIS, UNNCESSARY -#[allow(dead_code)] -/// Read two f32 buffers back to CPU in **one** encoder+submit+poll pair. -fn readback_f32_pair( - device: &wgpu::Device, - queue: &wgpu::Queue, - src_re: &wgpu::Buffer, - src_im: &wgpu::Buffer, - len: usize, -) -> Option<(Vec, Vec)> { - let bytes = (len * std::mem::size_of::()) as u64; - let stg_re = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("stg_re_f32"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let stg_im = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("stg_im_f32"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("readback_f32_pair"), - }); - enc.copy_buffer_to_buffer(src_re, 0, &stg_re, 0, bytes); - enc.copy_buffer_to_buffer(src_im, 0, &stg_im, 0, bytes); - queue.submit(std::iter::once(enc.finish())); - device.poll(wgpu::Maintain::Wait); - - stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::Maintain::Wait); - - let re_data = stg_re.slice(..).get_mapped_range(); - let im_data = stg_im.slice(..).get_mapped_range(); - let re = bytemuck::cast_slice::(&re_data).to_vec(); - let im = bytemuck::cast_slice::(&im_data).to_vec(); - drop(re_data); - drop(im_data); - stg_re.unmap(); - stg_im.unmap(); - Some((re, im)) -} - -// REMOVE THIS, UNNCESSARY -/// Single-buffer f32 readback kept for CPU-fallback / test paths. -#[allow(dead_code)] -fn readback_f32( - device: &wgpu::Device, - queue: &wgpu::Queue, - src: &wgpu::Buffer, - len: usize, -) -> Option> { - let bytes = (len * std::mem::size_of::()) as u64; - let staging = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("readback_f32"), - size: bytes, - usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, - mapped_at_creation: false, - }); - - let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { - label: Some("readback_f32_encoder"), - }); - encoder.copy_buffer_to_buffer(src, 0, &staging, 0, bytes); - queue.submit(std::iter::once(encoder.finish())); - device.poll(wgpu::Maintain::Wait); - - let slice = staging.slice(..); - slice.map_async(wgpu::MapMode::Read, |_| {}); - device.poll(wgpu::Maintain::Wait); - - let data = slice.get_mapped_range(); - let out = bytemuck::cast_slice::(&data).to_vec(); - drop(data); - staging.unmap(); - Some(out) -} - /// The function receives flat arrays from C++ and returns a heap-allocated array. #[no_mangle] pub extern "C" fn sonar_wgpu_compute( @@ -504,10 +345,35 @@ pub extern "C" fn sonar_wgpu_compute( // map_async is asynchronous -> it requests CPU access to the buffer but doesn't block. // Map staging buffers (GPU work already done above) -> POLL 2 - buf.stg_re.slice(..).map_async(wgpu::MapMode::Read, |_| {}); - buf.stg_im.slice(..).map_async(wgpu::MapMode::Read, |_| {}); + let re_ok = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let im_ok = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let re_ok_cb = std::sync::Arc::clone(&re_ok); + let im_ok_cb = std::sync::Arc::clone(&im_ok); + + buf.stg_re + .slice(..) + .map_async(wgpu::MapMode::Read, move |r| { + re_ok_cb.store(r.is_ok(), std::sync::atomic::Ordering::Release); + }); + buf.stg_im + .slice(..) + .map_async(wgpu::MapMode::Read, move |r| { + im_ok_cb.store(r.is_ok(), std::sync::atomic::Ordering::Release); + }); device.poll(wgpu::Maintain::Wait); + let re_mapped = re_ok.load(std::sync::atomic::Ordering::Acquire); + let im_mapped = im_ok.load(std::sync::atomic::Ordering::Acquire); + if !re_mapped || !im_mapped { + eprintln!( + "[sonar_wgpu] map_async failed: stg_re_ok={}, stg_im_ok={}", + re_mapped, im_mapped + ); + buf.stg_re.unmap(); + buf.stg_im.unmap(); + return std::ptr::null_mut(); + } + let (mut p_re, mut p_im) = { let re_data = buf.stg_re.slice(..).get_mapped_range(); let im_data = buf.stg_im.slice(..).get_mapped_range(); diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs index 1596a937..8fafdcdd 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs @@ -281,52 +281,44 @@ fn compile_pipeline( }) } -/// GPU context initialization: Vulkan adapter enumeration, device request. -fn init_gpu_context() -> Option { - // TODO: These environment variable overrides are NVIDIA/Vulkan-specific (Linux only). - // Replace with wgpu::Backends::all() so the adapter selection works automatically - // on any platform (Metal on macOS, DX12 on Windows, Vulkan on Linux) without - // needing manual ICD path configuration. - if std::env::var("VK_ICD_FILENAMES").is_err() { - unsafe { - std::env::set_var("VK_ICD_FILENAMES", "/usr/share/vulkan/icd.d/nvidia_icd.json"); - } - } - if std::env::var("VK_LAYER_PATH").is_err() { - unsafe { - std::env::set_var("DISABLE_LAYER_NV_optimus", "1"); - } - } - - // Catch panic to prevent GPU init failure from crashing Gazebo. +#[cfg(target_os = "linux")] +const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ + (wgpu::Backends::VULKAN, "vulkan"), + (wgpu::Backends::all(), "all"), +]; + +#[cfg(target_os = "windows")] +const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ + (wgpu::Backends::DX12, "dx12"), + (wgpu::Backends::all(), "all"), +]; + +#[cfg(target_os = "macos")] +const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ + (wgpu::Backends::METAL, "metal"), + (wgpu::Backends::all(), "all"), +]; + +#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] +const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[(wgpu::Backends::all(), "all")]; + +fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option { + eprintln!("[sonar_wgpu] Trying backend set: {label}"); + + // Catch panic to prevent GPU init failure from crashing Gazebo.Whtat let result = std::panic::catch_unwind(|| { - // TODO: Replace wgpu::Backends::VULKAN with wgpu::Backends::all() to support - // Metal (macOS), DX12 (Windows), and other backends automatically. - // The explicit VULKAN flag means this will fail silently on non-Vulkan platforms. let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { - backends: wgpu::Backends::VULKAN, + backends, ..Default::default() }); - // TODO: Same as above — enumerate_adapters(wgpu::Backends::all()) would pick up - // the best available backend on any platform instead of Vulkan-only. - let adapters: Vec<_> = instance - .enumerate_adapters(wgpu::Backends::VULKAN) - .into_iter() - .collect(); - - eprintln!("[sonar_wgpu] Vulkan adapters found: {}", adapters.len()); - for a in &adapters { - let info = a.get_info(); - eprintln!("[sonar_wgpu] {:?} -> {}", info.device_type, info.name); - } - - let adapter = adapters - .iter() - .find(|a| a.get_info().device_type == wgpu::DeviceType::DiscreteGpu) - .or_else(|| adapters.first())?; + let adapter = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: None, + force_fallback_adapter: false, + }))?; - eprintln!("[sonar_wgpu] Selected adapter: {}", adapter.get_info().name); + eprintln!("[sonar_wgpu] [{label}] selected adapter: {}", adapter.get_info().name); let (device, queue) = pollster::block_on(adapter.request_device( &wgpu::DeviceDescriptor { label: Some("sonar_wgpu_device"), @@ -336,7 +328,7 @@ fn init_gpu_context() -> Option { None, )) .map_err(|e| { - eprintln!("[sonar_wgpu] request_device failed: {e}"); + eprintln!("[sonar_wgpu] [{label}] request_device failed: {e}"); e }) .ok()?; @@ -360,20 +352,47 @@ fn init_gpu_context() -> Option { &device, "fft_pipeline", include_str!("shaders/fft.wgsl"), &fft_bgl, ); - eprintln!("[sonar_wgpu] GPU pipelines compiled in {:.0} ms -> ready.", t0.elapsed().as_millis()); + eprintln!( + "[sonar_wgpu] [{label}] GPU pipelines compiled in {:.0} ms -> ready.", + t0.elapsed().as_millis() + ); - Some(GpuContext { device, queue, bs_pipeline, bs_bgl, convert_pipeline, convert_bgl, mm_pipeline, mm_bgl, fft_pipeline, fft_bgl, buffers: std::sync::Mutex::new(None) }) + Some(GpuContext { + device, + queue, + bs_pipeline, + bs_bgl, + convert_pipeline, + convert_bgl, + mm_pipeline, + mm_bgl, + fft_pipeline, + fft_bgl, + buffers: std::sync::Mutex::new(None), + }) }); match result { Ok(ctx) => ctx, Err(_) => { - eprintln!("[sonar_wgpu] GPU init panicked; falling back to CPU path"); + eprintln!("[sonar_wgpu] [{label}] GPU init panicked; trying next backend set"); None } } } +/// GPU context initialization: adapter enumeration, device request. +fn init_gpu_context() -> Option { + for (backends, label) in BACKEND_ATTEMPTS { + if let Some(ctx) = try_init_gpu_context(*backends, label) { + return Some(ctx); + } + } + + eprintln!("[sonar_wgpu] GPU init failed for all backend sets; falling back to CPU path"); + None +} + /// Global singleton: lazily initializes GPU context on first access. /// Returns None if GPU unavailable; falls back to CPU implementation. pub fn get_or_init() -> Option<&'static GpuContext> { diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl index 5df59bec..b1e27318 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/matmul.wgsl @@ -24,9 +24,7 @@ fn main( ) { let row = gid.y; let col = gid.x; - if (row >= params.n_beams || col >= params.n_freq) { - return; - } + let in_bounds = (row < params.n_beams) && (col < params.n_freq); // Accumulator: accumulates partial products across tiles. var acc = 0.0; @@ -40,14 +38,14 @@ fn main( // LOAD: load tile data into shared memory. // Load A tile: each thread loads one A[row, k_a]. - if (k_a < params.n_beams) { + if (in_bounds && k_a < params.n_beams) { tile_a[lid.y][lid.x] = a[row * params.n_beams + k_a]; } else { tile_a[lid.y][lid.x] = 0.0; } // Load B tile: each thread loads one B[k_b, col]. - if (k_b < params.n_beams) { + if (in_bounds && k_b < params.n_beams) { tile_b[lid.y][lid.x] = b[k_b * params.n_freq + col]; } else { tile_b[lid.y][lid.x] = 0.0; @@ -67,5 +65,7 @@ fn main( // WRITE: final result to global memory. // Normalize output: divide by beam_corrector_sum. let norm = max(params.beam_corrector_sum, 1e-12); - c[row * params.n_freq + col] = acc / norm; + if (in_bounds) { + c[row * params.n_freq + col] = acc / norm; + } } From 4f9ad661b0417724da8926b7c8d79b5b776ad038 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 19:16:13 +0000 Subject: [PATCH 04/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../multibeam_sonar/sonar_compute_cpu.cc | 19 +++++---- .../multibeam_sonar/sonar_compute_cuda.cc | 39 +++++-------------- .../multibeam_sonar/sonar_compute_wgpu.cc | 2 +- 3 files changed, 23 insertions(+), 37 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc index 5290883c..92acdf0a 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cpu.cc @@ -14,8 +14,8 @@ // TODO: Move CreateComputeBackend() into its own file (e.g. sonar_compute_factory.cc) // so that the CPU and WGPU backends are fully decoupled and the factory is the // only translation unit that needs to know about both. -#include "sonar_compute_wgpu.hh" #include "sonar_compute_cuda.hh" +#include "sonar_compute_wgpu.hh" namespace gz { @@ -148,8 +148,9 @@ class CpuComputeBackend : public ComputeBackend std::unique_ptr CreateComputeBackend(const std::string & requestedBackend) { - std::cerr << "[sonar_compute_factory] CreateComputeBackend called with: " << requestedBackend << std::endl; - + std::cerr << "[sonar_compute_factory] CreateComputeBackend called with: " << requestedBackend + << std::endl; + std::string backend = requestedBackend; std::transform( backend.begin(), backend.end(), backend.begin(), @@ -157,7 +158,8 @@ std::unique_ptr CreateComputeBackend(const std::string & request if (backend == "wgpu") { - std::cerr << "[sonar_compute_factory] Attempting WgpuComputeBackend initialization..." << std::endl; + std::cerr << "[sonar_compute_factory] Attempting WgpuComputeBackend initialization..." + << std::endl; auto wgpuBackend = std::make_unique(); SonarComputeInput probe; if (wgpuBackend->Initialize(probe)) @@ -172,7 +174,8 @@ std::unique_ptr CreateComputeBackend(const std::string & request if (backend == "cuda") { - std::cerr << "[sonar_compute_factory] Attempting CudaComputeBackend initialization..." << std::endl; + std::cerr << "[sonar_compute_factory] Attempting CudaComputeBackend initialization..." + << std::endl; auto cudaBackend = std::make_unique(); SonarComputeInput probe; if (cudaBackend->Initialize(probe)) @@ -180,7 +183,8 @@ std::unique_ptr CreateComputeBackend(const std::string & request std::cerr << "[sonar_compute_factory] SUCCESS: CudaComputeBackend initialized!" << std::endl; return cudaBackend; } - std::cerr << "[sonar_compute_factory] CUDA backend requested but failed to initialize." << std::endl; + std::cerr << "[sonar_compute_factory] CUDA backend requested but failed to initialize." + << std::endl; return nullptr; } @@ -208,7 +212,8 @@ std::unique_ptr CreateComputeBackend(const std::string & request << std::endl; return wgpuBackend; } - std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected CPU backend (both CUDA and WGPU unavailable)" + std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected CPU backend (both CUDA and WGPU " + "unavailable)" << std::endl; } diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc index 8aea552e..6baa0836 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc @@ -50,8 +50,9 @@ bool CudaComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu return false; } - if (!input.depthImage || !input.normalImage || !input.reflectivityImage || - !input.window || !input.beamCorrector || input.elevation_angles.empty()) + if ( + !input.depthImage || !input.normalImage || !input.reflectivityImage || !input.window || + !input.beamCorrector || input.elevation_angles.empty()) { return false; } @@ -62,33 +63,13 @@ bool CudaComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu const double vPixelSize = input.vFOV / static_cast(input.nRays - 1); const NpsGazeboSonar::CArray2D beams = NpsGazeboSonar::sonar_calculation_wrapper( - *input.depthImage, - *input.normalImage, - hPixelSize, - vPixelSize, - input.hFOV, - input.vFOV, - hPixelSize, - input.vFOV / 180.0 * M_PI, - hPixelSize, - const_cast(input.elevation_angles.data()), - vPixelSize * (input.raySkips + 1), - input.soundSpeed, - input.maxDistance, - input.sourceLevel, - input.nBeams, - input.nRays, - input.raySkips, - input.sonarFreq, - input.bandwidth, - input.nFreq, - *input.reflectivityImage, - input.attenuation, - const_cast(input.window), - input.beamCorrector, - input.beamCorrectorSum, - false, - input.blazingFlag); + *input.depthImage, *input.normalImage, hPixelSize, vPixelSize, input.hFOV, input.vFOV, + hPixelSize, input.vFOV / 180.0 * M_PI, hPixelSize, + const_cast(input.elevation_angles.data()), vPixelSize * (input.raySkips + 1), + input.soundSpeed, input.maxDistance, input.sourceLevel, input.nBeams, input.nRays, + input.raySkips, input.sonarFreq, input.bandwidth, input.nFreq, *input.reflectivityImage, + input.attenuation, const_cast(input.window), input.beamCorrector, + input.beamCorrectorSum, false, input.blazingFlag); output.nBeams = input.nBeams; output.nFreq = input.nFreq; diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc index 59cd9ed8..c98afe42 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc @@ -68,7 +68,7 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu { if (!this->cpuFallback) { - std::cerr << "[sonar_wgpu] GPU unavailable on first compute -> creating CPU fallback backend" + std::cerr << "[sonar_wgpu] GPU unavailable on first compute -> creating CPU fallback backend" << std::endl; this->cpuFallback = CreateComputeBackend("cpu"); if (this->cpuFallback) From 494b0df83f43470f2346dfcdbcec90b16b685b54 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Mon, 13 Apr 2026 23:57:44 +0530 Subject: [PATCH 05/18] Add Zero-Padding & Remove CPU Fallback Signed-off-by: Naitik Pahwa --- .../wgpu_vendor/README.md | 40 ++++++ .../wgpu_vendor/sonar_wgpu_rust/Cargo.lock | 110 ----------------- .../wgpu_vendor/sonar_wgpu_rust/Cargo.toml | 2 - .../wgpu_vendor/sonar_wgpu_rust/src/fft.rs | 114 ------------------ .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 113 +++++++---------- .../sonar_wgpu_rust/src/shaders/fft.wgsl | 58 +++++---- 6 files changed, 116 insertions(+), 321 deletions(-) create mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md delete mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md new file mode 100644 index 00000000..c14df052 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md @@ -0,0 +1,40 @@ + +Data flow with buffer names: + + CPU INPUT DATA + │ + ▼ + depth_buf [n_beams × n_rays] f32 COPY_DST | STORAGE + normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE + refl_buf [n_beams × n_rays] f32 COPY_DST | STORAGE + window_buf [n_freq] f32 COPY_DST | STORAGE + bc_buf [n_beams × n_beams] f32 COPY_DST | STORAGE + │ + ▼ backscatter.wgsl (reads above, writes below) + │ + out_re_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← atomic accumulators + out_im_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← zeroed each frame + │ + ▼ convert.wgsl (i32 → f32) + │ + mm_re_in [n_beams × n_freq] f32 STORAGE + mm_im_in [n_beams × n_freq] f32 STORAGE + │ + ▼ matmul.wgsl (beam correction) + │ + mm_re_out [n_beams × n_freq] f32 STORAGE | COPY_SRC + mm_im_out [n_beams × n_freq] f32 STORAGE | COPY_SRC + │ + ▼ copied into FFT buffers + │ + p_re_buf [n_beams × fft_len] f32 STORAGE | COPY_SRC | COPY_DST + p_im_buf [n_beams × fft_len] f32 STORAGE | COPY_SRC | COPY_DST + │ + ▼ fft.wgsl (in-place FFT with zero padding to fft_len) + │ + ▼ copied to staging + │ + stg_re [n_beams × fft_len] f32 MAP_READ | COPY_DST + stg_im [n_beams × fft_len] f32 MAP_READ | COPY_DST + │ + ▼ CPU reads back first n_freq bins of final result diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock index 9e3c585d..3eb292fe 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.lock @@ -171,31 +171,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - [[package]] name = "d3d12" version = "0.20.0" @@ -216,12 +191,6 @@ dependencies = [ "litrs", ] -[[package]] -name = "either" -version = "1.15.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" - [[package]] name = "equivalent" version = "1.0.2" @@ -549,24 +518,6 @@ dependencies = [ "jni-sys", ] -[[package]] -name = "num-complex" -version = "0.4.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" -dependencies = [ - "num-traits", -] - -[[package]] -name = "num-integer" -version = "0.1.46" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" -dependencies = [ - "num-traits", -] - [[package]] name = "num-traits" version = "0.2.19" @@ -644,15 +595,6 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf8e6a8aa66ce33f63993ffc4ea4271eb5b0530a9002db8455ea6050c77bfa" -[[package]] -name = "primal-check" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08" -dependencies = [ - "num-integer", -] - [[package]] name = "proc-macro2" version = "1.0.106" @@ -689,26 +631,6 @@ version = "0.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" -[[package]] -name = "rayon" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "368f01d005bf8fd9b1206fb6fa653e6c4a81ceb1466406b81792d87c5677a58f" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.5.18" @@ -730,20 +652,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" -[[package]] -name = "rustfft" -version = "6.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89" -dependencies = [ - "num-complex", - "num-integer", - "num-traits", - "primal-check", - "strength_reduce", - "transpose", -] - [[package]] name = "rustversion" version = "1.0.22" @@ -783,8 +691,6 @@ version = "0.1.0" dependencies = [ "bytemuck", "pollster", - "rayon", - "rustfft", "wgpu", ] @@ -803,12 +709,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strength_reduce" -version = "0.2.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82" - [[package]] name = "syn" version = "1.0.109" @@ -860,16 +760,6 @@ dependencies = [ "syn 2.0.117", ] -[[package]] -name = "transpose" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e" -dependencies = [ - "num-integer", - "strength_reduce", -] - [[package]] name = "unicode-ident" version = "1.0.24" diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml index 6d8fc301..78965cd2 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/Cargo.toml @@ -10,8 +10,6 @@ crate-type = ["staticlib"] wgpu = { version = "0.20" } bytemuck = { version = "1", features = ["derive"] } pollster = "0.3" -rayon = "1" -rustfft = "6" [profile.release] opt-level = 3 diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs deleted file mode 100644 index ad40dfc8..00000000 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/fft.rs +++ /dev/null @@ -1,114 +0,0 @@ -// CPU FFT using rustfft for both power-of-two and arbitrary lengths. - -use crate::ComplexF; -use rayon::prelude::*; -use rustfft::{num_complex::Complex, FftPlanner}; - -/// Batched FFT preserving the existing public API used by lib.rs. -pub fn fft_batched(input: &[ComplexF], n_beams: usize, n_freq: usize) -> Vec { - let mut rows: Vec> = (0..n_beams) - .map(|b| input[b * n_freq..(b + 1) * n_freq].to_vec()) - .collect(); - - let mut planner = FftPlanner::::new(); - let fft = planner.plan_fft_forward(n_freq); - - rows.par_iter_mut().for_each(|row| { - let mut rustfft_row: Vec> = row - .iter() - .map(|x| Complex:: { re: x.re, im: x.im }) - .collect(); - - fft.process(&mut rustfft_row); - - for (dst, src) in row.iter_mut().zip(rustfft_row.iter()) { - dst.re = src.re; - dst.im = src.im; - } - }); - - rows.into_iter().flatten().collect() -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::ComplexF; - - /// Reference O(N²) DFT used only in tests for correctness comparison. - fn naive_dft(data: &mut [ComplexF]) { - let n = data.len(); - let input: Vec = data.to_vec(); - for k in 0..n { - let mut re_sum = 0.0f32; - let mut im_sum = 0.0f32; - for j in 0..n { - let angle = -2.0 * std::f32::consts::PI * (k as f32) * (j as f32) / (n as f32); - re_sum += input[j].re * angle.cos() - input[j].im * angle.sin(); - im_sum += input[j].re * angle.sin() + input[j].im * angle.cos(); - } - data[k] = ComplexF { re: re_sum, im: im_sum }; - } - } - - fn nearly_eq(a: f32, b: f32) -> bool { - (a - b).abs() < 1e-3 - } - - fn nearly_eq_rel(a: f32, b: f32) -> bool { - let mag = b.abs().max(1e-6); - (a - b).abs() / mag < 1e-2 // 1% relative tolerance for f32 accumulation over N=399 - } - - #[test] - fn rustfft_matches_naive_dft_n3() { - let x = vec![ - ComplexF { re: 1.0, im: 0.0 }, - ComplexF { re: 2.0, im: -1.0 }, - ComplexF { re: 0.5, im: 3.0 }, - ]; - let x_b = fft_batched(&x, 1, 3); - let mut x_d = x.clone(); - naive_dft(&mut x_d); - for (b, d) in x_b.iter().zip(x_d.iter()) { - assert!(nearly_eq(b.re, d.re), "re: {} vs {}", b.re, d.re); - assert!(nearly_eq(b.im, d.im), "im: {} vs {}", b.im, d.im); - } - } - - #[test] - fn rustfft_matches_naive_dft_n399() { - use std::f32::consts::PI; - let n = 399usize; - // Construct a chirp-like test signal - let input: Vec = (0..n) - .map(|k| ComplexF { - re: ((k as f32) * PI / 50.0).cos(), - im: ((k as f32) * PI / 75.0).sin(), - }) - .collect(); - let x_b = fft_batched(&input, 1, n); - let mut x_d = input.clone(); - naive_dft(&mut x_d); - // Compare first 5 and last 5 bins - for &i in &[0, 1, 2, 3, 4, 394, 395, 396, 397, 398] { - assert!(nearly_eq_rel(x_b[i].re, x_d[i].re), - "bin {i} re: {} vs {}", x_b[i].re, x_d[i].re); - assert!(nearly_eq_rel(x_b[i].im, x_d[i].im), - "bin {i} im: {} vs {}", x_b[i].im, x_d[i].im); - } - } - - #[test] - fn rustfft_impulse_gives_constant_spectrum() { - // DFT of [1, 0, 0, ..., 0] = [1, 1, 1, ..., 1] - let n = 399usize; - let mut x = vec![ComplexF::default(); n]; - x[0].re = 1.0; - let x = fft_batched(&x, 1, n); - for (i, v) in x.iter().enumerate() { - assert!(nearly_eq(v.re, 1.0), "bin {i} re = {} (expected 1)", v.re); - assert!(nearly_eq(v.im, 0.0), "bin {i} im = {} (expected 0)", v.im); - } - } -} diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs index 9b4e5c81..c12c16bf 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs @@ -1,23 +1,12 @@ /// C-FFI interface, Buffer initialization and write ups and reads + Bind groups created + encoder setup + memory free -/// + Compute passes for each shader + gpu/cpu path selection +/// + Compute passes for each shader -mod fft; // CPU FFT + Bluestein mod pipeline; // GPU context + buffer management use bytemuck::{Pod, Zeroable}; - -#[derive(Clone, Copy, Default)] -pub struct ComplexF { - pub re: f32, - pub im: f32, -} use std::sync::atomic::{AtomicU64, Ordering}; use wgpu::util::DeviceExt; -fn is_power_of_two(x: usize) -> bool { - x != 0 && (x & (x - 1)) == 0 -} - #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct BackscatterParams { @@ -54,9 +43,10 @@ struct MatmulParams { #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct FftParams { - n_beams: u32, - n_freq: u32, - log2_n: u32, + n_beams: u32, + n_freq: u32, // actual number of frequency bins (may not be power-of-2) + padded_n: u32, // next power of 2 >= n_freq (actual FFT size, <= 4096) + log2_n: u32, // log2(padded_n) } /// The function receives flat arrays from C++ and returns a heap-allocated array. @@ -242,7 +232,8 @@ pub extern "C" fn sonar_wgpu_compute( }); let spectrum_bytes = (spectrum_len * std::mem::size_of::()) as u64; - let gpu_fft = n_freq_us <= 4096 && is_power_of_two(n_freq_us); + // Zero-pad n_freq to the next power of 2 for the GPU FFT (max 4096 = shared memory limit). + let padded_n = (n_freq_us.next_power_of_two() as u32).min(4096); // ---- Single command encoder: all passes + staging copy in one GPU submission ---- let mut enc = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { @@ -300,44 +291,43 @@ pub extern "C" fn sonar_wgpu_compute( } } - // FFT Dispatch (optional, only if n_freq is power-of-two and <= 4096); re/im in same encoder - if gpu_fft { - // copy MM output → FFT buffers → dispatch fft shader → copy to staging - enc.copy_buffer_to_buffer(&buf.mm_re_out, 0, &buf.p_re_buf, 0, spectrum_bytes); - enc.copy_buffer_to_buffer(&buf.mm_im_out, 0, &buf.p_im_buf, 0, spectrum_bytes); - - let fft_params = FftParams { n_beams, n_freq, log2_n: n_freq_us.ilog2() }; - let fft_param_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { - label: Some("fft_param"), - contents: bytemuck::bytes_of(&fft_params), - usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, - }); - let fft_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { - label: Some("fft_bg"), - layout: &ctx.fft_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 0, resource: fft_param_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 1, resource: buf.p_re_buf.as_entire_binding() }, - wgpu::BindGroupEntry { binding: 2, resource: buf.p_im_buf.as_entire_binding() }, - ], + // FFT Dispatch: always runs on GPU via zero-padding to the next power of 2 (max 4096). + // Copy MM output -> FFT working buffers -> dispatch FFT shader -> copy to staging. + enc.copy_buffer_to_buffer(&buf.mm_re_out, 0, &buf.p_re_buf, 0, spectrum_bytes); + enc.copy_buffer_to_buffer(&buf.mm_im_out, 0, &buf.p_im_buf, 0, spectrum_bytes); + + let fft_params = FftParams { + n_beams, + n_freq, + padded_n, + log2_n: padded_n.ilog2(), + }; + let fft_param_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("fft_param"), + contents: bytemuck::bytes_of(&fft_params), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + let fft_bg = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("fft_bg"), + layout: &ctx.fft_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 0, resource: fft_param_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 1, resource: buf.p_re_buf.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: buf.p_im_buf.as_entire_binding() }, + ], + }); + { + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: Some("fft_pass"), + timestamp_writes: None, }); - { - let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { - label: Some("fft_pass"), - timestamp_writes: None, - }); - pass.set_pipeline(&ctx.fft_pipeline); - pass.set_bind_group(0, &fft_bg, &[]); - pass.dispatch_workgroups(n_beams, 1, 1); - } - // ... fft dispatch ... then copy FFT output to staging for readback, all in same encoder - enc.copy_buffer_to_buffer(&buf.p_re_buf, 0, &buf.stg_re, 0, spectrum_bytes); - enc.copy_buffer_to_buffer(&buf.p_im_buf, 0, &buf.stg_im, 0, spectrum_bytes); - } else { - // CPU FFT path: copy MM output directly to staging - enc.copy_buffer_to_buffer(&buf.mm_re_out, 0, &buf.stg_re, 0, spectrum_bytes); - enc.copy_buffer_to_buffer(&buf.mm_im_out, 0, &buf.stg_im, 0, spectrum_bytes); + pass.set_pipeline(&ctx.fft_pipeline); + pass.set_bind_group(0, &fft_bg, &[]); + pass.dispatch_workgroups(n_beams, 1, 1); } + // Copy FFT output to staging for CPU readback. + enc.copy_buffer_to_buffer(&buf.p_re_buf, 0, &buf.stg_re, 0, spectrum_bytes); + enc.copy_buffer_to_buffer(&buf.p_im_buf, 0, &buf.stg_im, 0, spectrum_bytes); // Submit the entire frame in a single GPU command -> POLL 1 queue.submit(std::iter::once(enc.finish())); @@ -384,25 +374,6 @@ pub extern "C" fn sonar_wgpu_compute( buf.stg_re.unmap(); buf.stg_im.unmap(); - // CPU FFT fallback (n_freq not power-of-two; uses Bluestein O(N log N)) - if !gpu_fft { - let fft_t0 = std::time::Instant::now(); - let mut complex = vec![ComplexF::default(); spectrum_len]; - for i in 0..spectrum_len { - complex[i].re = p_re[i]; - complex[i].im = p_im[i]; - } - let fallback = fft::fft_batched(&complex, n_beams_us, n_freq_us); - for i in 0..spectrum_len { - p_re[i] = fallback[i].re; - p_im[i] = fallback[i].im; - } - if gpu_frame == 0 || gpu_frame % 50 == 49 { - eprintln!("[sonar_wgpu] CPU FFT: {:6.1} ms ({} beams x {} freq)", - fft_t0.elapsed().as_secs_f64() * 1000.0, n_beams, n_freq); - } - } - // Matches CUDA's post-FFT * delta_f scaling let delta_f = bandwidth / (n_freq as f32); for i in 0..spectrum_len { diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl index 7dec6e45..4c460853 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl @@ -1,20 +1,23 @@ -// Cooley-Tukey FFT (power-of-2 sizes only, N≤4096), or CPU fallback for arbitrary N. +// Cooley-Tukey FFT with zero-padding to the next power of 2. +// Supports any n_freq <= 4096. The input is padded to padded_n (next power of 2 >= n_freq) +// in shared memory; only the first n_freq output bins are written back per beam. struct Params { - n_beams: u32, - n_freq: u32, - log2_n: u32, + n_beams: u32, + n_freq: u32, // actual number of frequency bins (may not be power-of-2) + padded_n: u32, // next power of 2 >= n_freq (actual FFT size, <= 4096) + log2_n: u32, // log2(padded_n) }; @group(0) @binding(0) var params: Params; @group(0) @binding(1) var p_re: array; @group(0) @binding(2) var p_im: array; -// WGSL writes to shared memory (smem_re/im) -> fast scratchpad local to workgroup. +// Shared scratchpad for in-place FFT: sized for the maximum supported padded_n. var smem_re: array; var smem_im: array; -// Bit reversal for FFT permutation. +// Bit-reversal permutation used by the Cooley-Tukey butterfly. fn bit_reverse(v: u32, bits: u32) -> u32 { var x = v; var y: u32 = 0u; @@ -25,45 +28,52 @@ fn bit_reverse(v: u32, bits: u32) -> u32 { return y; } -// Cooley-Tukey FFT kernel with bit-reversal, butterfly stages, and writeback. +// One workgroup per beam. Threads: 256 per workgroup. @compute @workgroup_size(256, 1, 1) fn main( - @builtin(workgroup_id) wg: vec3, // (wg) -> which beam this workgroup handles - @builtin(local_invocation_id) lid: vec3 // (lid) -> which thread within the workgroup (0..255) + @builtin(workgroup_id) wg: vec3, // wg.x = beam index + @builtin(local_invocation_id) lid: vec3 // lid.x = thread 0..255 ) { let beam = wg.x; - if (beam >= params.n_beams || params.n_freq > 4096u) { + if (beam >= params.n_beams || params.padded_n > 4096u) { return; } - let n = params.n_freq; - let base = beam * n; + let n = params.padded_n; // FFT size (power of 2) + let n_real = params.n_freq; // actual data count stored in the buffer + let base = beam * n_real; // flat offset into the (non-padded) p_re/p_im buffers - // Bit-reversal permutation. + // Load with zero-padding into bit-reversed positions: + // indices [0, n_real) come from the buffer; [n_real, n) are zero-padded. for (var i: u32 = lid.x; i < n; i = i + 256u) { let j = bit_reverse(i, params.log2_n); - smem_re[j] = p_re[base + i]; - smem_im[j] = p_im[base + i]; + if (i < n_real) { + smem_re[j] = p_re[base + i]; + smem_im[j] = p_im[base + i]; + } else { + smem_re[j] = 0.0; + smem_im[j] = 0.0; + } } workgroupBarrier(); - // Cooley-Tukey butterfly iterations. + // Cooley-Tukey butterfly iterations over log2(padded_n) stages. for (var s: u32 = 0u; s < params.log2_n; s = s + 1u) { - let half = 1u << s; + let half = 1u << s; let stride = half << 1u; for (var i: u32 = lid.x; i < n / 2u; i = i + 256u) { let group = i / half; - let j = i % half; - let i0 = group * stride + j; - let i1 = i0 + half; + let j = i % half; + let i0 = group * stride + j; + let i1 = i0 + half; - // Twiddle factor: root-of-unity exp(-j*2*pi*k/N). + // Twiddle factor: exp(-j * 2*pi * k / stride) let angle = -2.0 * 3.14159265358979323846 * f32(j) / f32(stride); let wr = cos(angle); let wi = sin(angle); - // Butterfly combine: complex multiply (u ± w*t). + // Butterfly: u ± w*t let tr = smem_re[i1] * wr - smem_im[i1] * wi; let ti = smem_re[i1] * wi + smem_im[i1] * wr; let ur = smem_re[i0]; @@ -78,8 +88,8 @@ fn main( workgroupBarrier(); } - // Writeback to global storage. - for (var i: u32 = lid.x; i < n; i = i + 256u) { + // Writeback: only the first n_real bins per beam; buffer layout stays n_beams * n_freq. + for (var i: u32 = lid.x; i < n_real; i = i + 256u) { p_re[base + i] = smem_re[i]; p_im[base + i] = smem_im[i]; } From b15574de48e95589923be8fd47625ef52c8d88b6 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Wed, 20 May 2026 20:10:08 +0530 Subject: [PATCH 06/18] Add Demo Guide to run Multi-Backend Signed-off-by: Naitik Pahwa --- gazebo/DEMO_GUIDE.md | 84 +++++++++++++++++++ .../wgpu_vendor/README.md | 40 --------- 2 files changed, 84 insertions(+), 40 deletions(-) create mode 100644 gazebo/DEMO_GUIDE.md delete mode 100644 gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md diff --git a/gazebo/DEMO_GUIDE.md b/gazebo/DEMO_GUIDE.md new file mode 100644 index 00000000..6fb3fb04 --- /dev/null +++ b/gazebo/DEMO_GUIDE.md @@ -0,0 +1,84 @@ +# Multibeam Sonar Demo Guide + +Build and run the GPU multibeam sonar demo. The plugin supports wgpu (Vulkan), CUDA (NVIDIA), and CPU backends—pick one at launch without recompiling. + +## Setup + +**Prerequisites:** +- Ubuntu 24.04 +- ROS 2 Rolling +- Gazebo Jetty (gz-sim 10) +- Rust/Cargo +- Vulkan driver +- CUDA 12+ (if using the CUDA backend) + +Clone and check out the branch: +```bash +git clone https://github.com/naitikpahwa18/dave.git +cd dave +git checkout wgpu_integration +``` + +## Build + +Build in two steps on a fresh clone (the Rust library must be ready before the plugin can link to it): + +```bash +source /opt/ros/rolling/setup.bash + +# Step 1: build and install the Rust library +colcon build --packages-select wgpu_vendor +source install/setup.bash + +# Step 2: build the plugin and everything else +colcon build --packages-select dave_demos dave_worlds dave_interfaces multibeam_sonar multibeam_sonar_system dave_multibeam_sonar_demo dave_sensor_models +source install/setup.bash +``` + +On subsequent builds, you can run all packages in one command. + +## Run + +```bash +ros2 launch dave_multibeam_sonar_demo multibeam_sonar_demo.launch.py compute_backend:=wgpu +``` + +Use `compute_backend:=cuda` for CUDA, `compute_backend:=cpu` for CPU, or `compute_backend:=auto` to pick the best available (tries wgpu -> cuda -> cpu). + +You'll see RViz2 launch with a point cloud display. The sonar fan should update in real time. Check the terminal for initialization messages and per-frame timing. + +## Troubleshooting + +**`Could not find wgpu_vendorConfig.cmake`** -> You skipped Step 1. Build `wgpu_vendor` first, source install, then build the rest. + +**`Another world of the same name is running`** -> Kill stale Gazebo with `pkill -9 -f gz` and try again. + +**CUDA backend won't initialize** -> Run `nvidia-smi` to check if the driver is loaded. If not, reinstall or reload with `sudo modprobe nvidia`. + + +## How It Works + +Each frame, Gazebo renders depth and surface normals. The sonar plugin reads these and runs acoustic physics on your selected backend. The wgpu backend dispatches four compute shaders: backscatter (acoustic return per ray), convert (fixed-point i32 -> f32), matmul (beam correction), and FFT (range compression). Output goes to ROS 2 topics as a point cloud and sonar image via ros_gz_bridge. The Rust library compiles to a static library linked into the C++ Gazebo plugin via C FFI. + +## Data Flow + +**Pipeline stages:** + +1. **Input Buffers** - CPU writes depth, normal maps, reflectivity, window function, beam correction matrix +2. **backscatter.wgsl** - Computes acoustic return per ray using Lambert model, outputs to atomic accumulators +3. **convert.wgsl** - Converts fixed-point i32 results to f32 +4. **matmul.wgsl** - Applies beam correction matrix to each beam +5. **fft.wgsl** - Performs in-place FFT with zero-padding to power-of-2 for range compression +6. **Readback** - CPU reads first n_freq bins from staging buffers +7. **Output** - Results published to ROS 2 as point cloud and sonar image + +**Buffer details:** + +| Buffer | Dimensions | Type | Usage | +|--------|-----------|------|-------| +| depth_buf, normal_buf, refl_buf | n_beams × n_rays | f32 | Input from Gazebo | +| out_re_i32, out_im_i32 | n_beams × n_freq | i32 | Atomic accumulators (zeroed each frame) | +| mm_re_in, mm_im_in | n_beams × n_freq | f32 | After convert pass | +| mm_re_out, mm_im_out | n_beams × n_freq | f32 | After beam correction | +| p_re_buf, p_im_buf | n_beams × fft_len | f32 | FFT input/output (zero-padded) | +| stg_re, stg_im | n_beams × fft_len | f32 | Staging for CPU readback | \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md deleted file mode 100644 index c14df052..00000000 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/README.md +++ /dev/null @@ -1,40 +0,0 @@ - -Data flow with buffer names: - - CPU INPUT DATA - │ - ▼ - depth_buf [n_beams × n_rays] f32 COPY_DST | STORAGE - normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE - refl_buf [n_beams × n_rays] f32 COPY_DST | STORAGE - window_buf [n_freq] f32 COPY_DST | STORAGE - bc_buf [n_beams × n_beams] f32 COPY_DST | STORAGE - │ - ▼ backscatter.wgsl (reads above, writes below) - │ - out_re_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← atomic accumulators - out_im_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← zeroed each frame - │ - ▼ convert.wgsl (i32 → f32) - │ - mm_re_in [n_beams × n_freq] f32 STORAGE - mm_im_in [n_beams × n_freq] f32 STORAGE - │ - ▼ matmul.wgsl (beam correction) - │ - mm_re_out [n_beams × n_freq] f32 STORAGE | COPY_SRC - mm_im_out [n_beams × n_freq] f32 STORAGE | COPY_SRC - │ - ▼ copied into FFT buffers - │ - p_re_buf [n_beams × fft_len] f32 STORAGE | COPY_SRC | COPY_DST - p_im_buf [n_beams × fft_len] f32 STORAGE | COPY_SRC | COPY_DST - │ - ▼ fft.wgsl (in-place FFT with zero padding to fft_len) - │ - ▼ copied to staging - │ - stg_re [n_beams × fft_len] f32 MAP_READ | COPY_DST - stg_im [n_beams × fft_len] f32 MAP_READ | COPY_DST - │ - ▼ CPU reads back first n_freq bins of final result From 7402f0ca928a575c305d642b8eab4be849def8a8 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Fri, 22 May 2026 23:13:05 +0900 Subject: [PATCH 07/18] working update for macosx --- gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md | 88 +++++++++++++++++++ .../multibeam_sonar/CMakeLists.txt | 48 ++++++---- .../multibeam_sonar/MultibeamSonarSensor.cc | 2 +- .../multibeam_sonar_system/CMakeLists.txt | 83 ++++++++--------- 4 files changed, 156 insertions(+), 65 deletions(-) create mode 100644 gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md diff --git a/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md new file mode 100644 index 00000000..a55c17c3 --- /dev/null +++ b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md @@ -0,0 +1,88 @@ +# Multibeam Sonar Demo Guide +Build and run the GPU multibeam sonar demo. The plugin supports wgpu (Vulkan), and CPU backends—pick one at launch without recompiling. + +![Multibeam Sonar Demo](https://github.com/user-attachments/assets/8fbe8a3f-e917-42af-a51d-c49f133da882) + +## Setup + +### Native Installation of ROS2 Jazzy and Gazebo Harmonic +- Follow the instructions in this guide to set up ROS2 Jazzy and Gazebo Harmonic natively on Apple Silicon: https://github.com/IOES-Lab/ROS2_Jazzy_MacOS_Native_AppleSilicon + +### ROS_GZ for Mac +- Install ROS_GZ following the instructions here: https://github.com/IOES-Lab/ROS_GZ_MacOS_Native_AppleSilicon + +### RUST and Cargo +- Install Rust and Cargo using the official installer: https://www.rust-lang.org/tools/install + ```bash + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh + ``` + +### Vulkan SDK +- Install with brew: + ```bash + brew install vulkan-tools + ``` + +## Build + +Build in two steps on a fresh clone (the Rust library must be ready before the plugin can link to it): + +```bash +source /opt/ros/jazzy/setup.bash + +# Step 1: build and install the Rust library +colcon build --packages-select wgpu_vendor +source install/setup.bash + +# Step 2: build the plugin and everything else +colcon build --packages-select dave_demos dave_worlds dave_interfaces multibeam_sonar multibeam_sonar_system dave_multibeam_sonar_demo dave_sensor_models +source install/setup.bash +``` + +On subsequent builds, you can run all packages in one command. + +## Run + +```bash +ros2 launch dave_multibeam_sonar_demo multibeam_sonar_demo.launch.py compute_backend:=wgpu +``` + +Use `compute_backend:=cuda` for CUDA, `compute_backend:=cpu` for CPU, or `compute_backend:=auto` to pick the best available (tries wgpu -> cuda -> cpu). + +You'll see RViz2 launch with a point cloud display. The sonar fan should update in real time. Check the terminal for initialization messages and per-frame timing. + +## Troubleshooting + +**`Could not find wgpu_vendorConfig.cmake`** -> You skipped Step 1. Build `wgpu_vendor` first, source install, then build the rest. + +**`Another world of the same name is running`** -> Kill stale Gazebo with `pkill -9 -f gz` and try again. + +**CUDA backend won't initialize** -> Run `nvidia-smi` to check if the driver is loaded. If not, reinstall or reload with `sudo modprobe nvidia`. + + +## How It Works + +Each frame, Gazebo renders depth and surface normals. The sonar plugin reads these and runs acoustic physics on your selected backend. The wgpu backend dispatches four compute shaders: backscatter (acoustic return per ray), convert (fixed-point i32 -> f32), matmul (beam correction), and FFT (range compression). Output goes to ROS 2 topics as a point cloud and sonar image via ros_gz_bridge. The Rust library compiles to a static library linked into the C++ Gazebo plugin via C FFI. + +## Data Flow + +**Pipeline stages:** + +1. **Input Buffers** - CPU writes depth, normal maps, reflectivity, window function, beam correction matrix +2. **backscatter.wgsl** - Computes acoustic return per ray using Lambert model, outputs to atomic accumulators +3. **convert.wgsl** - Converts fixed-point i32 results to f32 +4. **matmul.wgsl** - Applies beam correction matrix to each beam +5. **fft.wgsl** - Performs in-place FFT with zero-padding to power-of-2 for range compression +6. **Readback** - CPU reads first n_freq bins from staging buffers +7. **Output** - Results published to ROS 2 as point cloud and sonar image + +**Buffer details:** + +| Buffer | Dimensions | Type | Usage | +|--------|-----------|------|-------| +| depth_buf, normal_buf, refl_buf | n_beams × n_rays | f32 | Input from Gazebo | +| out_re_i32, out_im_i32 | n_beams × n_freq | i32 | Atomic accumulators (zeroed each frame) | +| mm_re_in, mm_im_in | n_beams × n_freq | f32 | After convert pass | +| mm_re_out, mm_im_out | n_beams × n_freq | f32 | After beam correction | +| p_re_buf, p_im_buf | n_beams × fft_len | f32 | FFT input/output (zero-padded) | +| stg_re, stg_im | n_beams × fft_len | f32 | Staging for CPU readback | \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index 1849a906..7525acb0 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -15,18 +15,19 @@ set(CMAKE_CUDA_ARCHITECTURES "${CUDA_ARCHITECTURE}") project(multibeam_sonar) find_package(ament_cmake REQUIRED) -find_package(gz-cmake REQUIRED) -find_package(gz-sim REQUIRED) -find_package(gz-sensors REQUIRED) -find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) -find_package(gz-transport REQUIRED) -find_package(gz-msgs REQUIRED) +find_package(gz-cmake3 REQUIRED) +find_package(gz-sim8 REQUIRED) +find_package(gz-sensors8 REQUIRED) +find_package(gz-rendering8 REQUIRED OPTIONAL_COMPONENTS ogre ogre2) +find_package(gz-transport13 REQUIRED) +find_package(gz-msgs10 REQUIRED) find_package(rclcpp REQUIRED) find_package(sensor_msgs REQUIRED) find_package(geometry_msgs REQUIRED) find_package(OpenCV REQUIRED) find_package(marine_acoustic_msgs REQUIRED) find_package(cv_bridge REQUIRED) +find_package(Eigen3 REQUIRED) find_package(wgpu_vendor REQUIRED) find_package(CUDAToolkit QUIET) @@ -67,32 +68,41 @@ if(CUDAToolkit_FOUND) endif() target_include_directories(${PROJECT_NAME} PUBLIC - ${gz-sensors_INCLUDE_DIRS} - ${gz-rendering_INCLUDE_DIRS} - ${gz-msgs_INCLUDE_DIRS} - ${gz-transport_INCLUDE_DIRS} - ${gz-sim_INCLUDE_DIRS} + ${gz-sensors8_INCLUDE_DIRS} + ${gz-rendering8_INCLUDE_DIRS} + ${gz-msgs10_INCLUDE_DIRS} + ${gz-transport13_INCLUDE_DIRS} + ${gz-sim8_INCLUDE_DIRS} ${rclcpp_INCLUDE_DIRS} ${sensor_msgs_INCLUDE_DIRS} ${geometry_msgs_INCLUDE_DIRS} ${OpenCV_INCLUDE_DIRS} + ${EIGEN3_INCLUDE_DIRS} ${marine_acoustic_msgs_INCLUDE_DIRS} ${cv_bridge_INCLUDE_DIRS} ) target_link_libraries(${PROJECT_NAME} - gz-sim::gz-sim - gz-sensors::gz-sensors - gz-msgs::gz-msgs - gz-transport::gz-transport - gz-rendering::gz-rendering + gz-sim8::gz-sim8 + gz-sensors8::gz-sensors8 + gz-msgs10::gz-msgs10 + gz-transport13::gz-transport13 + gz-rendering8::gz-rendering8 ${rclcpp_LIBRARIES} ${sensor_msgs_LIBRARIES} ${geometry_msgs_LIBRARIES} ${OpenCV_LIBS} + Eigen3::Eigen ${marine_acoustic_msgs_LIBRARIES} ) +if(APPLE) + target_link_libraries(${PROJECT_NAME} + "-framework Metal" + "-framework QuartzCore" + ) +endif() + if(TARGET cv_bridge::cv_bridge) target_link_libraries(${PROJECT_NAME} cv_bridge::cv_bridge) elseif(TARGET cv_bridge) @@ -136,10 +146,10 @@ if(DEFINED wgpu_vendor_DIR) endif() endif() -if(TARGET gz-sensors::gz-sensors-rendering) - target_link_libraries(${PROJECT_NAME} gz-sensors::gz-sensors-rendering) +if(TARGET gz-sensors::gz-sensors8-rendering) + target_link_libraries(${PROJECT_NAME} gz-sensors::gz-sensors8-rendering) else() - find_library(GZ_SENSORS_RENDERING_LIB gz-sensors-rendering) + find_library(GZ_SENSORS_RENDERING_LIB gz-sensors8-rendering) if(GZ_SENSORS_RENDERING_LIB) target_link_libraries(${PROJECT_NAME} ${GZ_SENSORS_RENDERING_LIB}) endif() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index e49eb4f9..3a3be3a3 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -1359,7 +1359,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() sonar_image_data.beam_count = this->nBeams; // this->sonar_image_raw_msg_.data_size = 1; // sizeof(float) * nFreq * nBeams; std::vector intensities; - int Intensity[this->nBeams][this->nFreq]; + // int Intensity[this->nBeams][this->nFreq]; for (size_t r = 0; r < P_Beams[0].size(); r++) { diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt index 3787c76d..f3250188 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/CMakeLists.txt @@ -2,51 +2,44 @@ cmake_minimum_required(VERSION 3.11.0 FATAL_ERROR) project(multibeam_sonar_system) find_package(ament_cmake REQUIRED) -find_package(CUDAToolkit QUIET) - -if(CUDAToolkit_FOUND) - find_package(gz-cmake REQUIRED) - find_package(gz-common REQUIRED COMPONENTS profiler) - find_package(gz-plugin REQUIRED COMPONENTS register) - find_package(gz-rendering REQUIRED OPTIONAL_COMPONENTS ogre ogre2) - find_package(gz-sim REQUIRED) - find_package(gz-sensors REQUIRED) - - if(TARGET gz-rendering::ogre) - add_definitions(-DWITH_OGRE) - endif() - - if(TARGET gz-rendering::ogre2) - add_definitions(-DWITH_OGRE2) - endif() - - add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) - - add_library(${PROJECT_NAME} SHARED MultibeamSonarSystem.cc) - target_link_libraries(${PROJECT_NAME} - gz-common::gz-common - gz-plugin::gz-plugin - gz-sim::gz-sim - gz-sensors::gz-sensors - multibeam_sonar - ) - target_include_directories(${PROJECT_NAME} - PUBLIC - ../multibeam_sonar - ) - - # Install targets - install(TARGETS ${PROJECT_NAME} - DESTINATION lib/${PROJECT_NAME} - ) - - # Environment hooks - ament_environment_hooks( - "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in" - ) - -else() - message(STATUS "CUDA Toolkit not found: Skipping CUDA-specific targets") +find_package(gz-cmake3 REQUIRED) +find_package(gz-common5 REQUIRED COMPONENTS profiler) +find_package(gz-plugin2 REQUIRED COMPONENTS register) +find_package(gz-rendering8 REQUIRED OPTIONAL_COMPONENTS ogre ogre2) +find_package(gz-sim8 REQUIRED) +find_package(gz-sensors8 REQUIRED) + +if(TARGET gz-rendering8::ogre) + add_definitions(-DWITH_OGRE) endif() +if(TARGET gz-rendering8::ogre2) + add_definitions(-DWITH_OGRE2) +endif() + +add_subdirectory(../multibeam_sonar ${CMAKE_BINARY_DIR}/multibeam_sonar) + +add_library(${PROJECT_NAME} SHARED MultibeamSonarSystem.cc) +target_link_libraries(${PROJECT_NAME} +gz-common5::gz-common5 +gz-plugin2::gz-plugin2 +gz-sim8::gz-sim8 +gz-sensors8::gz-sensors8 + multibeam_sonar +) +target_include_directories(${PROJECT_NAME} + PUBLIC + ../multibeam_sonar +) + +# Install targets +install(TARGETS ${PROJECT_NAME} + DESTINATION lib/${PROJECT_NAME} +) + +# Environment hooks +ament_environment_hooks( + "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in" +) + ament_package() From 6aef91c823af5da073329b84ba617b572965e79e Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Mon, 25 May 2026 23:22:59 +0530 Subject: [PATCH 08/18] debugFlag wgpu + Fix formatting Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/MultibeamSonarSensor.cc | 7 +- .../multibeam_sonar/sonar_compute_backend.hh | 1 + .../multibeam_sonar/sonar_compute_cuda.cc | 2 +- .../multibeam_sonar/sonar_compute_wgpu.cc | 9 +- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 23 ++++ .../sonar_wgpu_rust/src/pipeline.rs | 103 +++++++----------- 6 files changed, 73 insertions(+), 72 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index 3a3be3a3..a3aaffe3 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -750,6 +750,7 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo prototype.soundSpeed = this->soundSpeed; prototype.attenuation = this->attenuation; prototype.sensorGain = this->sensorGain; + prototype.debugFlag = this->debugFlag; prototype.blazingFlag = this->blazingFlag; prototype.window = this->window; prototype.rangeVector = this->rangeVector; @@ -1203,6 +1204,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() input.soundSpeed = this->soundSpeed; input.attenuation = this->attenuation; input.sensorGain = this->sensorGain; + input.debugFlag = this->debugFlag; input.blazingFlag = this->blazingFlag; input.window = this->window; input.rangeVector = this->rangeVector; @@ -1239,10 +1241,11 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() if (debugFlag) { + double timeMs = duration.count() / 1000.0; RCLCPP_INFO_STREAM( this->ros_node_->get_logger(), this->computeBackend->Name() - << " Sonar Frame Calc Time " << duration.count() / 10000 - << "/100 [s]\n"); + << " Sonar Frame Calc Time " << std::fixed + << std::setprecision(3) << timeMs << " [ms]\n"); } // CSV log write stream diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh index b0750763..3e2e89ae 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_backend.hh @@ -31,6 +31,7 @@ struct SonarComputeInput double attenuation{0.0}; float sensorGain{0.02f}; + bool debugFlag{false}; bool blazingFlag{false}; const float * window{nullptr}; diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc index 6baa0836..3d3b2181 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_cuda.cc @@ -69,7 +69,7 @@ bool CudaComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu input.soundSpeed, input.maxDistance, input.sourceLevel, input.nBeams, input.nRays, input.raySkips, input.sonarFreq, input.bandwidth, input.nFreq, *input.reflectivityImage, input.attenuation, const_cast(input.window), input.beamCorrector, - input.beamCorrectorSum, false, input.blazingFlag); + input.beamCorrectorSum, input.debugFlag, input.blazingFlag); output.nBeams = input.nBeams; output.nFreq = input.nFreq; diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc index c98afe42..4fec4ada 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.cc @@ -14,7 +14,8 @@ extern "C" const float * beam_corrector_flat, const float * window_flat, uint32_t n_beams, uint32_t n_rays, uint32_t n_freq, uint32_t ray_skips, float sound_speed, float max_distance, float source_level, float attenuation, float sensor_gain, float bandwidth, float beam_corrector_sum, - float area_scaler, float h_fov, float v_fov, uint64_t frame_index, uint64_t seed); + float area_scaler, float h_fov, float v_fov, uint64_t frame_index, uint64_t seed, + bool debug_flag); void sonar_wgpu_free(float * ptr, size_t len); } @@ -38,7 +39,8 @@ bool WgpuComputeBackend::Initialize(const SonarComputeInput &) float * probe = sonar_wgpu_compute( depth.data(), normal.data(), reflectivity.data(), beamCorrector.data(), window.data(), 1u, 1u, - 4u, 0u, 1500.0f, 10.0f, 220.0f, 0.0f, 1.0f, 29500000.0f, 1.0f, 1e-6f, 0.0f, 0.0f, 0u, 1u); + 4u, 0u, 1500.0f, 10.0f, 220.0f, 0.0f, 1.0f, 29500000.0f, 1.0f, 1e-6f, 0.0f, 0.0f, 0u, 1u, + false); if (probe) { @@ -169,7 +171,8 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu static_cast(input.soundSpeed), static_cast(input.maxDistance), static_cast(input.sourceLevel), static_cast(input.attenuation), input.sensorGain, static_cast(input.bandwidth), input.beamCorrectorSum, area_scaler, - static_cast(input.hFOV), static_cast(input.vFOV), input.frameIndex, input.seed); + static_cast(input.hFOV), static_cast(input.vFOV), input.frameIndex, input.seed, + input.debugFlag); if (!result) { diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs index c12c16bf..f8a7c170 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/lib.rs @@ -4,6 +4,8 @@ mod pipeline; // GPU context + buffer management use bytemuck::{Pod, Zeroable}; +use std::fs::OpenOptions; +use std::io::Write; use std::sync::atomic::{AtomicU64, Ordering}; use wgpu::util::DeviceExt; @@ -49,6 +51,19 @@ struct FftParams { log2_n: u32, // log2(padded_n) } +fn emit_debug_timing(line: &str) { + eprintln!("{line}"); + + if let Ok(mut file) = OpenOptions::new() + .create(true) + .append(true) + .open("wgpu_debug_timings.txt") + { + let _ = writeln!(file, "{line}"); + let _ = file.flush(); + } +} + /// The function receives flat arrays from C++ and returns a heap-allocated array. #[no_mangle] pub extern "C" fn sonar_wgpu_compute( @@ -73,6 +88,7 @@ pub extern "C" fn sonar_wgpu_compute( v_fov: f32, frame_index: u64, seed: u64, + debug_flag: bool, ) -> *mut f32 { if depth_flat.is_null() || normal_flat.is_null() @@ -392,6 +408,13 @@ pub extern "C" fn sonar_wgpu_compute( } let elapsed_ms = gpu_t0.elapsed().as_secs_f64() * 1000.0; + if debug_flag { + emit_debug_timing(&format!( + "Total WGPU Calculation Wrapper Time: {:.3} ms", + elapsed_ms + )); + } + // Log on the very first frame and then every 50 frames. if gpu_frame == 0 || gpu_frame % 50 == 49 { let active_rays = (n_rays + std::cmp::max(1, ray_skips) - 1) / std::cmp::max(1, ray_skips); diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs index 8fafdcdd..d208d617 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/pipeline.rs @@ -1,6 +1,5 @@ /// Adapters, device, queue setups + BUFFERS SETUP + PIPELINE SETUPS + PIPELINE COMPILE + GPU CONTEXT INIT all in pipeline.rs /// + Shaders compiled - use std::sync::OnceLock; /// GPU buffer lifetime: persistent allocation across frames via queue.write_buffer. @@ -43,7 +42,9 @@ impl SonarBuffers { let i32_bytes = |n: usize| (n * std::mem::size_of::()) as u64; SonarBuffers { - n_beams, n_rays, n_freq, + n_beams, + n_rays, + n_freq, depth_buf: device.create_buffer(&wgpu::BufferDescriptor { label: Some("pb_depth"), size: f32_bytes(depth_ray), @@ -145,12 +146,12 @@ impl SonarBuffers { /// GPU compute context: device handle + compiled shader pipelines. /// Four pipelines compiled once at init: backscatter, convert, matmul, FFT. pub struct GpuContext { - pub device: wgpu::Device, // the GPU -> used to create everything - pub queue: wgpu::Queue, // the command queue -> used to submit work + pub device: wgpu::Device, // the GPU -> used to create everything + pub queue: wgpu::Queue, // the command queue -> used to submit work // Backscatter kernel: ray-surface interactions with Lambert scatter. - pub bs_pipeline: wgpu::ComputePipeline, // compiled backscatter shader - pub bs_bgl: wgpu::BindGroupLayout, // describes bs shader's buffer slots + pub bs_pipeline: wgpu::ComputePipeline, // compiled backscatter shader + pub bs_bgl: wgpu::BindGroupLayout, // describes bs shader's buffer slots // i32→f32 conversion: fixed-point atomic backscatter output to float spectrum. pub convert_pipeline: wgpu::ComputePipeline, @@ -201,7 +202,9 @@ fn make_uniform_entry(binding: u32) -> wgpu::BindGroupLayoutEntry { } /// Bind group layout builder: defines buffer bindings for each shader. -fn build_bgls(device: &wgpu::Device) -> ( +fn build_bgls( + device: &wgpu::Device, +) -> ( wgpu::BindGroupLayout, wgpu::BindGroupLayout, wgpu::BindGroupLayout, @@ -211,13 +214,13 @@ fn build_bgls(device: &wgpu::Device) -> ( let bs_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("bs_bgl"), entries: &[ - make_uniform_entry(0), // (params) - make_storage_entry(1, true), // (depth) true = read_only - make_storage_entry(2, true), // (normal) - make_storage_entry(3, true), // (reflectivity) - make_storage_entry(4, false), // (out_re_i32) false = read_write - make_storage_entry(5, false), // (out_im_i32) - make_storage_entry(6, true), // (window) + make_uniform_entry(0), // (params) + make_storage_entry(1, true), // (depth) true = read_only + make_storage_entry(2, true), // (normal) + make_storage_entry(3, true), // (reflectivity) + make_storage_entry(4, false), // (out_re_i32) false = read_write + make_storage_entry(5, false), // (out_im_i32) + make_storage_entry(6, true), // (window) ], }); @@ -318,7 +321,10 @@ fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option Option ready.", @@ -398,46 +412,3 @@ fn init_gpu_context() -> Option { pub fn get_or_init() -> Option<&'static GpuContext> { GPU_CONTEXT.get_or_init(init_gpu_context).as_ref() } - - -/* -Data flow with buffer names: - - CPU INPUT DATA - │ - ▼ - depth_buf [n_beams × n_rays] f32 COPY_DST | STORAGE - normal_buf [n_beams × n_rays × 3] f32 COPY_DST | STORAGE - refl_buf [n_beams × n_rays] f32 COPY_DST | STORAGE - window_buf [n_freq] f32 COPY_DST | STORAGE - bc_buf [n_beams × n_beams] f32 COPY_DST | STORAGE - │ - ▼ backscatter.wgsl (reads above, writes below) - │ - out_re_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← atomic accumulators - out_im_i32 [n_beams × n_freq] i32 COPY_DST | STORAGE ← zeroed each frame - │ - ▼ convert.wgsl (i32 → f32) - │ - mm_re_in [n_beams × n_freq] f32 STORAGE - mm_im_in [n_beams × n_freq] f32 STORAGE - │ - ▼ matmul.wgsl (beam correction) - │ - mm_re_out [n_beams × n_freq] f32 STORAGE | COPY_SRC - mm_im_out [n_beams × n_freq] f32 STORAGE | COPY_SRC - │ - ▼ copied into FFT buffers - │ - p_re_buf [n_beams × n_freq] f32 STORAGE | COPY_SRC | COPY_DST - p_im_buf [n_beams × n_freq] f32 STORAGE | COPY_SRC | COPY_DST - │ - ▼ fft.wgsl (in-place FFT) - │ - ▼ copied to staging - │ - stg_re [n_beams × n_freq] f32 MAP_READ | COPY_DST - stg_im [n_beams × n_freq] f32 MAP_READ | COPY_DST - │ - ▼ CPU reads back final result - */ \ No newline at end of file From 9a08fa988582deefa992893a6bbec0af7475fc9d Mon Sep 17 00:00:00 2001 From: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:13:45 +0900 Subject: [PATCH 09/18] fix: preserve range axis after padded WGPU FFT Signed-off-by: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> --- .../sonar_wgpu_rust/src/shaders/fft.wgsl | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl index 4c460853..d56e6b23 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/sonar_wgpu_rust/src/shaders/fft.wgsl @@ -1,6 +1,7 @@ // Cooley-Tukey FFT with zero-padding to the next power of 2. // Supports any n_freq <= 4096. The input is padded to padded_n (next power of 2 >= n_freq) -// in shared memory; only the first n_freq output bins are written back per beam. +// in shared memory. The padded FFT is then sampled back onto the original n_freq +// physical range grid before writeback. struct Params { n_beams: u32, @@ -88,9 +89,19 @@ fn main( workgroupBarrier(); } - // Writeback: only the first n_real bins per beam; buffer layout stays n_beams * n_freq. + // Range-preserving writeback. + // + // A target at original-grid bin k appears at approximately + // k * padded_n / n_real in the padded FFT. Copying smem[k] directly therefore + // stretches the reported range by padded_n / n_real and drops the far-range + // tail. Sample the padded spectrum at that scaled coordinate and linearly + // interpolate the complex components back to n_real output bins. for (var i: u32 = lid.x; i < n_real; i = i + 256u) { - p_re[base + i] = smem_re[i]; - p_im[base + i] = smem_im[i]; + let src = f32(i) * f32(n) / f32(n_real); + let lo = min(u32(floor(src)), n - 1u); + let hi = min(lo + 1u, n - 1u); + let alpha = src - f32(lo); + p_re[base + i] = mix(smem_re[lo], smem_re[hi], alpha); + p_im[base + i] = mix(smem_im[lo], smem_im[hi], alpha); } } From 050244daba08691888335fea59fd039b901eb4c7 Mon Sep 17 00:00:00 2001 From: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> Date: Tue, 8 Sep 2026 13:13:45 +0900 Subject: [PATCH 10/18] docs: record WGPU range-axis validation Signed-off-by: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> --- .../validation/wgpu_range_axis/README.md | 94 ++++++++++++++++++ .../wgpu_range_axis/rangebin_rmse.png | Bin 0 -> 48964 bytes .../wgpu_range_axis/rangebin_summary.csv | 9 ++ .../wgpu_range_axis/rangefix_comparison.png | Bin 0 -> 74300 bytes .../rangefix_paired_accuracy.csv | 17 ++++ 5 files changed, 120 insertions(+) create mode 100644 gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/README.md create mode 100644 gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_rmse.png create mode 100644 gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_summary.csv create mode 100644 gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_comparison.png create mode 100644 gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_paired_accuracy.csv diff --git a/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/README.md b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/README.md new file mode 100644 index 00000000..43e79fe0 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/README.md @@ -0,0 +1,94 @@ +# WGPU padded-FFT range-axis validation + +This note records the evidence for the range-preserving writeback candidate in +PR #44. The measurements were taken from the PR head at +`6aef91c823af5da073329b84ba617b572965e79e` and from the candidate shader change +applied on top of that commit. + +## Problem + +The WGPU path pads `n_freq` to the next power of two before running its FFT. For +the default sonar configuration, `n_freq = 399` and `padded_n = 512`. The +original shader copied `smem[i]` directly into output bin `i`, while the ROS +message continued to use the original 399-bin physical range vector. + +The expected error signature is therefore + +```text +reported range = physical range * padded_n / n_freq +``` + +For 399 -> 512, the scale is `1.283208`. With a 10 m configured maximum range, +the expected far-range clipping threshold is `10 * 399 / 512 = 7.792969 m`. + +## Candidate + +The candidate keeps the zero-padded power-of-two FFT. During writeback, output +bin `i` samples the complex padded spectrum at `i * padded_n / n_freq` and uses +linear interpolation between the adjacent samples. The output buffer and +published physical range vector remain `n_freq` bins long. + +## Controlled Gazebo experiment + +- Platform: Apple M2, Metal 3, macOS 15.7.3 +- Software: ROS 2 Lyrical, Gazebo Sim 10.5.0 +- Target: one planar panel with its front face placed at the commanded range +- Range: 2, 4, 6, and 8 m +- Incidence angle: 0, 15, 30, and 45 degrees +- Samples: five frames for each backend and condition +- Oracle: an independent PointCloud range measurement checked target placement +- Boundary exclusion: 10 m was excluded because the PointCloud oracle was not + available at the configured maximum range + +The peak range was extracted from the median profile over the five collected +frames. RMSE, MAE, bias, and maximum absolute error were then calculated against +the commanded front-face range. + +| Backend | RMSE (m) | MAE (m) | Max abs. error (m) | +| --- | ---: | ---: | ---: | +| PR #44 CPU | 0.0134 | 0.0111 | 0.0268 | +| Original PR #44 WGPU | 1.4532 | 1.3587 | 1.9582 | +| Candidate WGPU writeback | 0.0540 | 0.0449 | 0.1271 | + +The candidate reduced range RMSE by `96.28%` relative to the original WGPU +writeback in this test matrix. + +![Range-axis validation](rangefix_comparison.png) + +The 16 paired conditions and their measured values are available in +[`rangefix_paired_accuracy.csv`](rangefix_paired_accuracy.csv). + +## Range-bin checks + +The same mechanism was checked using other non-power-of-two range-bin counts. +These are four-distance exploratory checks rather than the full angle matrix. + +| `n_freq` -> FFT size | Original RMSE (m) | Candidate RMSE (m) | +| --- | ---: | ---: | +| 301 -> 512 | 4.7419 | 0.0616 | +| 399 -> 512 | 1.4645 | 0.0385 | +| 511 -> 512 | 0.0397 | 0.0562 | +| 513 -> 1024 | 4.9812 | 0.0110 | + +An unmodified `512 -> 512` power-of-two control produced `0.0252 m` RMSE, +supporting padding-grid mismatch as the dominant mechanism in these controlled +tests. + +![Range-bin RMSE](rangebin_rmse.png) + +The range-bin summaries are available in +[`rangebin_summary.csv`](rangebin_summary.csv). + +## Current limitations + +- NVIDIA CUDA equivalence has not yet been tested. +- The `511 -> 512` case did not benefit from linear interpolation. A no-op or + adaptive policy near a unity padding ratio should be reviewed before treating + this candidate as a universal solution. +- The evidence is limited to controlled Gazebo targets on Apple Metal. It does + not establish HIL, physical-sonar, or real-ocean accuracy. +- End-to-end timing samples overlap; they do not isolate the FFT writeback cost. + +The candidate should therefore be reviewed as a focused correction for the +observed padded-grid range mapping, not as a claim of complete backend +equivalence. diff --git a/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_rmse.png b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_rmse.png new file mode 100644 index 0000000000000000000000000000000000000000..5ea98969ed4643128424097d53a5663507f27875 GIT binary patch literal 48964 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 RnaBVD literal 0 HcmV?d00001 diff --git a/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_summary.csv b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_summary.csv new file mode 100644 index 00000000..ff6e7004 --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangebin_summary.csv @@ -0,0 +1,9 @@ +n_freq,padded_n,method,conditions,rmse_m,mae_m,bias_m,max_abs_error_m,unfixed_observable_limit_m,model_valid_conditions,model_residual_rmse_m +301,512,original,4,4.741873322498592,4.072062313556671,0.07206231355667114,8.0,5.87890625,2,0.09010430538758143 +301,512,linear,4,0.06162741057324949,0.04102003574371338,0.03880274295806885,0.11973428726196289,5.87890625,, +399,512,original,4,1.4645263599761196,1.3712374567985535,1.3712374567985535,1.9581937789916992,7.79296875,3,0.054589027573644026 +399,512,linear,4,0.038452310449590285,0.03553497791290283,0.03553497791290283,0.05183887481689453,7.79296875,, +511,512,original,4,0.03973169547237621,0.029047220945358276,0.027741700410842896,0.07310724258422852,9.98046875,4,0.0334465373876441 +511,512,linear,4,0.056245733426789915,0.04862925410270691,0.04732373356819153,0.07310724258422852,9.98046875,, +513,1024,original,4,4.9811570233532825,4.4811443402431905,0.49089726665988564,7.98049414716661,5.009765625,2,0.006504525225154364 +513,1024,linear,4,0.01097666912949125,0.009427905082702637,0.008127570152282715,0.018205642700195312,5.009765625,, diff --git a/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_comparison.png b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_comparison.png new file mode 100644 index 0000000000000000000000000000000000000000..3f28a2c02d336f8745b7a1c74ba52ea9e3c10813 GIT binary patch literal 74300 zcmdqJbyU>d`!&ohDu@!&jkL5dbSfgv&?O}`NGsi_lt@W4G}7H&3L-EtbVzr{&>ipL z{{Fu2djEdbyVmopXIKkoaN={$xz2U=wfDXzSOqGBjYW=yhK7bMCo83fhW79!8XEc} z<~{J2mxbsK;Gc&k3Nlh?w|773&AHKNXwT5(q+V&bC2!9-dlAY|VC)G+G*ZysdxSSV zlVZZcnJw5c!=f_%O@z?;BMbB+CNVzmeN5WlGb>TUrxDmmNL@}^29nvi)ArnynUtxN z#;K_uae>1p3Y_r-;M4wdGw9$%J^b$jwD(`oi2wUwo%ep(fA7D{rTz5azYm15|6%{{ z{Q*nNcmMp)gHO=cXpjHXr3C(O{D_Y|jdxeO=Z6>EL7UbV ztu_?;1ly*FBbjOk(fH=>`u6-@qCI}OrkK{c+qEB};eJr}D+Ct7@e4WxsaSRp(&Xpi znRgI8$_cc67#JXJgTZ(XQR8DD) zOiGd|t*WhT@o|Z7PH}b#S7DD+VLLv)fA62$M*bXj?;HHlyq#wSy(Z=Ok|^$4t2#mw zmb3P?I0K&ygiDQERNmg|oVDjSLO7DVp=u>)!~kXZpSy*s7&b zA08TpRGf*XW@NPZrBu6W))FgiX_hSKUFCfr_}bZTSTLRO@}~edze8DV$?8hqQi1p* zdOA~-_)#v2r?b6cUui zPqg&VbDR|Fy#p%E**b@@fq%qW=E|d@)Ekdd_895j8hhL5|9Bw~fyd*p7^UZ$rou*o zq!w|Xo=1(7rl`H*>VS}xNI2g-)J)}d8CVorLez@gt=N4q{(#P<_v zpUf6ymXY=K(@99md;D&+cqQfZ_;RMPK#z9K+O9lj#I!=C#0T*k0bMz3j!Zxvt~QABnWibnnwKU zOEhGOMTq+bA9x-t4pn;={dk*-=Mm8m7TL4%D41oi{1FM~+^qK=A(gdW>#@qMcuMTl zBgRU{;U5Qf_t0W;dA7z9<5K0~*@ULRV5;&70^^iSos=Fl6Go8Fx{mC%tjye<1D_=E zeipn-zcBz+0JPS@dEWah#%~p*4K!>y^b+C2RgSF`v0$zSDd`yvk&#$=d2kBhPVx!3v^M zlKzLuA`!JSD#HYgjYa7AF#dUgKC)+~cO+q!FztKOZI_V8VE3<+zb&J0W}{H_&>+iSogj1HBJ_t_m(V57xAO&D8rKq9dlijc5I5* zgTS_&IemOaN}d{%Yz4DYo9StGyo4bRMa6xe#-z|^A;d=ubQ0LYDbLSXMES)dSsR5# z-a~$_rOr7@%D&fOXlCN)jC?C+Ia(xc;Ik+Ay``|oC|6j_O(r{LBveM(sOfS~h>yds zf61e+zRGsAYj8HFi|K*y+11}$O-Yk)I_%sKzLG`Ne9O#9@;>57*(>37Qyn)aD=TR$ zk3d<5)%`lV5~vdSn1f4|dP!O&T#4BU0$!n+$x6!rVY^H<`q_Az!NOt*gE3WGY1obz zJUx8!b_{oAI&^|s(#B*aAhft7P`Agwe@hw5G_d3%e+9>XoZxDp9aSU;AO)%P0m>--G% z4I^_d8TqDXUA>dl_y}T1e}&q?@zAjO8stEPgycGmhWOxdBVQVY(f{L0CuEw}&NQAAK=vc6vUsifL4PwImv zIa$U1l1;5v=&#-=PPq74;YW zWd4a%{n$1w`WoHag1K=uY%9-8EQAdlr=v-y<@>?;mhk-i4F}24FzjYgw%Ye^wa*aVta*4U z*F*3RtGx*bqoU%Vv0~Hu>peXnI@e3l(7nA^9*M1IzIUTj6cVbx;ljEg89&)qyE%SS zFV|~j#ifpIB_xtxr)Zh8k}b%~K7{HH=~_RlI(njRO7AMLriG*+tapsmxjLE29VqLn zstS#%y2@#25ahR1Q9l=1nYkSJy*v^Vkr63zi1@X%|$2*3sOt`zZY#MgJ}k5gq$LD<-=bn zPED~M@4yYieUM*nX~3=)1!0$guA`jBmh-FO@|o{ddVX+Ze*Q-(Gj`hL-PgYL`p~fX zOwq?~3e01+lX(MFgQK_9LykTKlB@lc*VjI(s^ zfK$Kqc;KepBLA7}%-h+?`(-7wZZGUNU=8*w~o#!G+wmTT3`q}>>rkG z;8J7o5F8GkiOvrEGZWuSRf;#$=x}{=Wv;iLg1bItzEty?q;xiuo{Dg+f+N#+PKiB^${u_rBP09i(rcJ9)vuKg>#x^1zByDr4s2dxJP z3v5hne|ddj-G9*ZQ9YiSF;^|bmpz&HWIQdlP%djIEvP>|wWXzHw$WlO|5!mACHmng zxuHiRh+k=pGp$7FLz>odw&3%#;YFe;lmwQFPX9vL*4V*V*_tNB>5h<_Fq??X${0Tf zMTwDkd&fkoVWr*SFNJXwWXlX@4r$fG+0~hCc4RAPavm>NU$a*+kDXHi=lpuG0!1yA z)(tWg2|L{$gUuePBKB7Fl)GaZ)VAkgMGleCa9pMPt! zzbi9n&-quCI(h_9M2k31_lk&>HeJrhc|m&4@M9r_Jv?Q?ems-?BD#fwjQr^AVr|>C zZCj8&QnqEQoR{K-$ry+@!XMiH->0XWHV((a;1yNZ%9*##4l=YJZ!m+y6eziUBHN0c z-*6jW&~*0et5g5O8Yg=~BQ);&o4ve1!2Eo2#!m*R8BXw2^58(K5hr;Y;$|fct7*#t zxLI?S7+-SP;rv+7N9;mLBSGL33j=SHWl+XtM6&u5t}RXmJHoEmj)18GFFX7kUE zOrgMzcCCeZYw0An?*wt|>52dMfihW`grK~elwy3#k^o9L=RgRyhIu>fUS&PEj}Y)w zOsb*T<$I4v!MH&f#{;;V%$SsrrvGg&nuPTZK*AMW77kJj{c@hSnpA~`vdZ&o0bf6WB-Yf6`5($bZ?-9|dL|LGgYgEctq7$(`Zrk4biz6R~o@CP3}9D`L3dP|%!AxiGv| z`tO@c8t>-p1m*S`BK3tFbkcU;4#Lg3h(W{|G~6g7;<4WrE5ceR z=5_VO=fXgQHn&x!v{>9OZ(VdFs%78-^FW;_%1A+^Fsl*I?`gS!$~?`Pf5=5jgNWWXeK zDjUr-6n=fj2+-?Q>npUvMa#)D;+dIeT*^nAJ2D>8vJ5^)+l-oaqfP%fqP2m!g^Oe~ z+Lh}R-rH7^8;)rSkU!V0jW z&CbQ#%PlXT1|UF4PPf&S<{9N))EQkWQ9{a;dID`+ZWtN&G+(65ga#vFQ&YOy$40-Fq^y=p-_OX(jTV1V&RXxS zKLpapG^f@Qt9w=R5yl|Sv`Osg{JpLSp&d$RqK1ql7y#5=Ff#ggnN_sk>TCEA2DQ7C zRi2cn)!T8PtPFR1pO*T6$E7top%%{E{R(nBD@Mn+t?iB^CuQkaFjb(;1-Tjk{^mjr zt+O+5&|EnzgwrY-FAQp|e!fww z_cV>KCx^S8##H)@Gf~Wi-Slq-APD}l^sIMkl`IQ-4JZ%$P>faTQ8dUHt2u1`Wl?Bl ze1}{+OitOh(l{&vQ(2H(*v5RiT))*@_8_Po)cC|RxQui>F7!+E?uWYs9+)~va!u(c z8^ucKvn`J&%2D^5*SB>(1lC#(zFZ+I2@fvMfah~p8qvnLXOzlL9?h;Cx>IQP{b(Ke zBH@+8%u4H@+_E=USWA=VaLD9*aWi6=q;aDPru z$Mh_y7{@o_UNeVoD%&EENY87&ZOzg4SfPjv9l8vY`-IB4$;WC9wxmapI+7-cuHcZgv$foYwL^FG3m3BKQoUkwO(9Bt+GTJees1l{x#WD% zjB8uU3oRoA+weO{{3BXVoYb5kLTrEl+P}f)DyDpzMU+TKTN`RIs%4$I6GT0o<*j^D zV`ea*;WQHwElWfpy5n~K+V^oQW*(kyX=NrdJ4@WIZ}i;I!vEtRU+0yy>pk46yvAX@#*-mxxv9zdoWJw--wk zwJU2_>sdP#uCW|yvJ?~0W~PqsS&1|24KI&0225KLZ_&!OS*NjMXeidh@3XBgUF}F+ z+D)fh)t_B+#KV>209TG1C|M{!c8vaY-Eg`)TVh3|y?Mhg#ArKIq%N#aY8(m2M%#TmelF)`1Sj5S>N~*@`$wN zAE>>%`;x#ezEq*!BhMkD@T9lhR! zqsPsoV}BgL)^xUZAFln%kuntbY_ zrM0>h*Ppku^yXCBax%}Q)XsJtt3^x%N(uQ6tptgkxSzVSDSP}t49nb!iCj2c zuWg|$Ng!Vx?BZr7A;z$lQzrlI={gl!q|$geN6rg6txKE~RqC(u8*`1rF(3VA^dH}& z1Yrm$)R*n*drq#Zed<~>Q>K&t8Am3BrfRAWPF%)rTq9_v&pW#^ny0GX4>ZZ8fHYT7 zSOiT;H2jPUwVNw@CwseFoH%Mfn+3H^`=V+;tI5hH1po<{9Me=$^4U|s`f;T~`f?kX zXGGeE+H7{KzBo#=#ty2YNn$!aVX4%9maTdCB^ZaWA=RMu`0MWLwavw@P%w?G^@1jz%_D$NiKL(v9*_U>D{j3hOHN8UU3T}e_>pV$ z*dH%IYNYo@#UF2F->;j+^473E^I$INSw{=4BDk-h+4$kSL*Qh32b?TLmvf%*H zWUXD1kg@HAqr>y{V*1FuuYxZc4`ZH9h4)zvW^qF9ABJ*k{{+>5lP)MPEQiwPLp}B{ zn)!nY7Y#oc74VRKdl-&Wk}p*{ACE177bwy-w?jw`&>K2n=2{ z&(VWm?ZV0`b$Vb5mCgO06XtG*d$jX|(#bUNvRbsZdQErzT8+}IptQ91CgMv=94<}k z*%s=t^-Ztx7R=bL;oU0f4?gz{NK>eT0buv6noI`GJoW)83<{&ECWvoVYP8?ZQE+=m zbvxKP1E1?Z#iIzNpIVVhNcZ`oaF9d z#3Le5PP@{_u^1^6M>r?)7a)^ge?KRXjilVha*i6k`Gx^j?iTljf0?A!WFCJ=$B&y& zP9k8dr`}Q$5x?(uyR@(QL z+h+MUBP-h-|Ezh;vgyKS6}2g&s-L`5Ta;3mAz439$akw6NyJ(bcx*Kwku)r)h>x${ z)^->@U8z$vDXtky(-q3@bwQbdEDsGOWL_Okm0Wvjp8SLLxoID_0O}OE5LqtPShHWC zraQBjEu;K+=uiITOIK{tW8(0!n*%{uh8B%Qo zFRZf9N*SdAVne&gSgWKL;$?Y5wren|)bo$WoLuRyt#WE36hnlm-|&sZFQO1;w$pDz zy%U2aAN>*|Qd=1AfHo++!p;-771<;8>mwz628L$>CiGO$>D%dM(-*P4$Q0u#Mo$M- zD(hOdEYjeN!^4?T4N?Z!o1^KC7(?pDR1cY6)W`X!POe_ZIyyKA*oicKoHE9?aD(es+Dv9bx$+-zZ!rJOP$F1;XS;K} zH4}{W?x`fFe*J5VGHP+GtlXy~1J|3LMmdWFkNX5jn5)OJZI@S9o4pPf6x^K5ob;V+ z?#23Uiez9o3p`wICIG$09unlSSsvm=L)~I z{^g{Sgk;y0YZ_2438IJBe|KX&D#N73iXLDIea_(jp`LP8Cu7?NVEDnDUs&{^ii)Ah zlr~$VC1bfVjXcnrdy_8)xYq4nc4ny!4Q<4#<%gzHDW*_ne6N2ViwIAvzc|fj<08}j z>!bpRz9pHHxktFutN--^)ZgBBG=J-{TAkzr;0{nHS(bu&lX;VOuinf(A&}%fA~1Fl zlxJ)H%}G~&wkJUXu;b0ej*N4DH9{Mvbu!!9m)z>K<=qv}Lu#p4D6Kh{N|nbjjGyoH z`z#<>bQ~}l4(E!1`Zr^$ami6NrgOo7@OVEV z5!nsS+SROu!+2Zc5`A7zn~$E*(fkcQV-4R`DgUfJGI7980&16VqPcuTNMurpRO}@! zd7Uh>2~{5UkHw3L}$ zm3{tFw`N`dRS;Xdb1ozFofbcM+*bTKf`B7gEbeW)S8+mj0^y5(-x4*S>?k=oCDd(C zQM|pzvguHyHc-{_?s&+x*Jc+!99NFV$pAqs80EqrM|@SSiviPF?R&|yDt4FrJI$@P ztncLi*H|y8OXr(?&R4I~BOURh)nW@R53PX2?OQslspGzRg^i^DrTJF)Rsh9nH+6FJ zdaXDY$j8`vxWQ5;&WE3UBus*rvFJ;MRsd-n(&97rtpmVT^~M~aY|@TAZ?is?zFq7Q zmCC#j2kb^~kl7k0Mns|>e>@AUOD@APjUUKV3ri%k=ZR-g8;w3E@U*Acj|viu<`Emq zU`5d6ANp!)X!P>x&`1PGcpZFcVX+!*`hXTv?sl5d8p=%@>43{FDGPi88b3qu@b`GW z3Q2hDs*(0~ZBu5jM@QC7jOY|j%)P2~`m-+KSb3EMRO2s=PSo+2CmTXYS`JtjY!>A^ zR2u+fHB+Dc<9x%8UwxB zo!OTH@_nJKCc6)$DEE39TPrBdo80)(^T6QNWOB-#Lo8MIQI@%{Q`3uD>dUeu-ci+}X(MZWgzF{?0T=IYn4`z+oL zz|MamEqDG~V?|?AZcJ z3#@Ct67V4bOpp)@7)@vzTb-hTX!+=9N91e)XQolGE>D}VlZz;ax%nEUNbDR@V&_Wg zt>_B`6Q)4Aqxg+nU~t8Lr<&RetU*s-WfvD_XY zlAnhoStFY&5Ky(Zfc9zceBk$<+$V*$2f!%gXt1HLswHo<@GF#rT&v4<;qHnd>p~LG zA6R7&_Ej)huhPY&LH4T|@+0|HW-d+fD+MVh4JXm&zeOKY?Cpn=((xMfYhCyZLO_L= zpgS~KW25u%j`nV6{_JjKk@mOc^bAxar&pDN`KR_TR_oMOrzGf-iIs^)Wdrnn+5M|8p2q`aL&><|NKVp=n8dYD|3|v@HDXAF8dXmE1VT)9y7F;_Q3FkxT~3y!Kq_`#yN+6$G`o z*&6XIQjQikJofC>Yk*H^2e{kKxzGEaTVrv=%HxgQ#+_~f)d4WsErvJLkTUbo}ZGB9!OpTP?!CHMtu-+h^1tVxz z1)czxt}4;&Eb4zCa64HD2zPHryVImVr}z<8OD5*oAVF51a2N3W(t$ry%eAiU`d6_ET+#?TKr(1E_E@lsk!c zb)Ur|fMo5HG)OS8wzUlh%(u6Xm2zpCs3(zFMFbvQoa~tW`4;d`j?jg4Bnq9!t6Apy z8T`VVxA<&NRPj+___KEGd5cl=`FFU998V1dT_cgDn*aLxDf>H(_(N(H7r zffEyLAh+~B_X9lCggO@Ez1rGJ!%C;k<%z=l4*%aVa+TUjNws6uQJEQ^TVxsJW#zL^ zU!08lX#8p{NQ(C3;PcX?i#?$ftapuY*)4(QTap2Oi6=;FH<*ObP)Yh0?m+Q1)h(R>bVK$~-$UoZq$IeCm^^^6(B{sQ-NBNmkk=+^2tS$6zi7nc5vnb3fMK+r66csU zEJWFA?4u5fdS~0F_($fF{fuvcgH@Y-r%5?+KiMfGU7&l1`r7NRc<-Qom2qt1kZNE8 z1aGQl>|xc=?VN-wEHbH^kc3#SSYECIf<^)J4A7A}Y<2Do2Daj|G_|^&8Db4pK*oF& z5!&p2`Y453Vd9&!=lbWj-X2xCnbgFqIl$=&um2!c?beSv&p#B1A&6%4BskiKsam*& zFgf?wurRq0?$hUAai3=sCIIcDWyAyx%68g>Gts_#S|p2w-TqDbq?*~tBGk1 zHCi|M?S=B@7BV6QTPio^1)h2U9L=G4ssZuN3ABW=w}z?jSKICk5l01!@|bMA>{0GB zD)XzG%Z3y1%_+9%ZjiE3qs_%ZNsiL6lgwPM(POQg^^HdfzPRkV_1-Ii%^)D;x-Fys z;0(dqI&Zv1jG9~`u!aG!0XufK{4_0bv=H}};-^iBymKPr0MB?HpFubADy64z<5& zr#ES%C%j}1zpS1^9Wupe9p1lpgF5`946h7)+uQdPVEW)e26gHPy$V(B?2d`S-P%EP zew45@2UBj=-B4yXHy0DW$MkN0J=y_EJ>uB2cK2L{DVmIQog;xeM-`n0_8{X0>bkQG z7*vJ^&w-J5{b#LfEFu@zo>bh9R?I&R51))mCL-tucW&y48c1(Ix)%RmB^`!+n?0?(Y(G$pNG$)dq zA+xi$Xto@+;cz_&vzzsnG(y_gvacaYk<-G)8n*-%C4=$t6CnZ@1`>*{Jws9U{o#fD zyA~}qtnFSl{GK4^w(@YPgJ7GIKb#}+?YUDKu5 zp#1&N{Gj8Zh;ncTbH72Z02uj%j;>XLipihf#;rFs2|llYDJ9sQ&N(&~D4sJ(BoRps zR~N-!&JB0Py-HMy-J^wp<%?@#`d{ZOt1+f=$DiIMD5hnx)zz~AxSb!~kd)Frb|z+w zDFx=*>tj=(#X@h1GDb{XT(GIK<(-eZW8zC`%M}R+~_B zxs^bGJIGUe3r-Z@l2pglbtY%`U%q71hr6+UpkgB>&8v&%h44`iwO*zuT*3{)8eFaF z0WZLep%;L_-a6M8t}j+Tb(yScvgu6_Y_h23XDYx)BO@b!LUS}+9C8`cDBPg!(sGWP zJ%>n7OzN=Om{d*xYoL&;&EY`+LF}5&8L^+wZjJ4=*Gg@^^WlYd|27c2by=tj&4_!+ zD?gm*1dv1nyVCC9?k1e7W9NlVmY8qKY||Dg(Wf(DX5*&!vL4MtT#W-CIxx0To>tpG zQ~h?!U$nO5y0ka4|GKESm*^CVnbyDcw`ODRa=e#y8ltK|p^Z%Bb@?uN70|hYLvc17 z3lWK3J0cA30B}0H{q)cxT-B{~!scXhWq8Bd6xa@iMnytw{)Ec4gVuvWo@slt_b+Vb zm20_n*^+j(CxqH2La?M|IE`MYE@&(qx)u7Q$#Y3dcNAYwhA4RgDkF?yB#Xo#d52=` zP*t}QSnDuGjB+kc-j^Pz&MkscsU&%FumEn7D*yS@?bi2MS56ugfor?Qv9O43-E1$~ z0==Hgxg|aL`CyDQYTD{D@WDv{WFJmSVRcJoW%mAq+vzt0Uru1d}6Tubfj+E*doWl#~<75q+ z&+*8J;uBM8=MRyJVKXzyg>~9$B(^c~=63^;Y+YUrW=A5eE;X{Dp}j@hF)>&BJiY1x z?tz6~kB5dJ!>y~fs^@Rtbo^FeRKqUy69#Soz@GIR(E(rsOwYZFVJRuwg__R8FxF;Z zvjHAskTo87rB=tjX`UQqAL%59+t;?iUv?vrnIdZWUQeY=KF;^d1Fl9ivTJgv$!^CA zI63^kgD#1xswa||N$oB!?4*0o30H?w{&Dv*?B+p0=6KN20jZKv(;koGQj*~RZ#i}4D1tKBtkH;({!pifvrc+j!Z5ru&%ERnoIteyT z%I^YTzrk>2*S;(4B)l&0FNAm)`3&e{6O$4JMJCQQ4zEtwJ;5^f-Hsf|}#A7s_vb+*s2A1w4E){|b|kg^`c`a4Ux7O=?L;vm$pZ zOHA6E$aoWZMSYW>A?aB$yxwABG8jn@oeR|GWR5JP7St61p8|Z~w`Ut6F9iChN3!CS zHLZU9-KI2FQ}f}HB(d;}mCy#!wAYo8mvGi38X29LqQ@w+s+Sg%oS2f5MytBw9863R zf3~^P88cEP8u|AR;zvVmW8fo7I5zO{0)7wSoZzj^ccpnpKimf0b&`Fek-8p3UvHcP zOiAg5cfmoiCva~31Kci-(4Fb3u3C#A9@{&Q8X6iQ&0QPXo}W&cR}ZLyHr|j}Tmrk~ zq>dgr*`@_-B`Yfjqsfbe@G7`u0B{GDG6MNqFEIaoX#@IQo7>qd%z*m#PL8_6pGHZD z8IkJg8spDxKZXB&%7D^c_uJ2i*PQW2E&KCl8-+kw>g`2nmAtbw9?MW#dL`jH4CDMy zQ{2GQf1ivi%NpD9n-MIh#TurggTHj(Luu)UvB^|1xxsg2HX7PY(EqnX&aCABZM^&6 z+B)}tM+4yh$|#7Wbu`o@)!w?--b0fQ(}zl#B_5`^!`E1mZOW6 z%c?lAdw>?h6sf}+yAZMW4YcNAEiD#X;NBCDab-hG9|4-P-m~-@h8d*PK`RmPMqdoTpki0oaF`J~G5?Bk3iEzgaXQpcGXA%tO z>ig?JI=k}%0j3|E8N#*pv-RGUbh!I-GtBgVxI`OVHSg>-G(xAHlQX9!ltti3uQ&j% z-<#==NAbVJf{sun#%Yf6HXb*NGcC5QjSYfU;upXX3Tz* z29p9tEgwu&x$ZI_xV@Bn_UXaP-}rKFQlV`KlG3>}K~Gy?i~-j0oG~fs&b!P`B<>yl z%%4)kz1}yiPj4J)TCPKmv(LhtjJLR97>tL1z>?f<+^FrMr3(?Kb^r6l8L;L1kGjcS zK%uj9Ez|c@(!ZV#*>8t3$|WiN%NZ$OPYLDYa(#{73|!L!C~;}~Fd(IEY`_u9x4?(z ziJLM}>&!xw+JjJ=vJTx;iHk`ts3-f+^`bEb()y!4kTleXUU^CEA6~om_bF3J_~SVo z{musF*O?i(%5% z?uw5#wFAiksPy3At=Ff?pK)>HNWwBk|a)@cjAz%+egiJsn0uiugLS zl@lv$PoFEolCq89+bv*f@&V-@HZ^vZOK?mB<)kgv~K8wK$s z0T(u+nfJD|(IUkT9L2cjguA-AN#Fr!(K@9TKP?*G*=fXsApy{P<7-n{Ub8+@k{?K~ zmeS_oWNB+n~)5|DS72*MEkgRSx zhy!KWnfzrRW8|JtiZ<^>6a&bVBEn0u=MMN9Xkzuj?CV=93KHT8qtze*H+2V%imE3# z%Vym|{K$i;)9BPxIZSQ{WKd=u;1EkoomKjIdt>$YAl%Q4?FbS72QJ#c>Vb?Vd}d(`f)A;0w!;^apg3T z59>j!3ytXU*+d}_?Lg+q;r!I`#DTjLz2G&YR&52ZMP`>z*(xxbIN)t6b}YqW($`I%QuK3QibYv-@X=SNJSQI&h}N0*AON1{H1 z2^m?r?Y5g{Vfhp6p8SP$qWfjvkZsduH=(Nj-vLU-N;nKL*7CbXV57pmoN!Km)|HJM z?(TbvFL~xwEWVmy^dqixMhUaKx7UY)a*>8q3KPz4=@yAj^q+4TK*Y0)aiC7H_qSfs zu}vo}oM4AnbF>;Tr@A^@1uzJHbv>>S5qj&UCbs6r z=EiaKy?_c_{O}i4#ZI6Fw6;A&8#N0o_U9qbB)dxdazT?-aiek_WgjR{L4y$WbSVX` z6no?qziEIh!)%jFNpSh(_gm1B&n7@fwQRz}JHYJEz?f^PF2uuX*HdD8^rJF!X5`W9 zK%DiAmGb{YD+W88XaOJ z8t>(nixT;|wVU6>_S_ZoU2Gy?YwOOkK+8#tfY9U=03KyrgW{w6+yjboo00DMT!B2V1TclRW2)mD~rONX{_Zx8LE z;oq^|*-rW_k51tNIn{EaDz)Ky;8lslc<&(Ivf4XyODLwCQV%$!AKmvt#sb?*4QzrJ zR8k!^6}*+Tl`y+(pJWvws?YjN%>K_h?u>~*IuWzfdEZx#E>+-ENEhE-S+UYaS+6gR zN&8V!*NajU)*Y;`96_3dhaEoAKVM?e-@<+-mH6|=_(=<2}< zB!?g)Pz?;>AJTldz!hj~1-jKhr@PDLc`cAs!$_yqGp49)d%vc6p1vP<{JBg%Dyq$% z8Uu$9d+x=Ei%lw%&dg1Z_~YRdQ#V^q8B2D7b_4GqXfE~f!Z3+s*FIz%Q2Hwdqqcyx zy2MTMK0fiw_F;A=l~ETG9Dq9=<97q>44f8+A=bU{taFRTM2FTq_)6cFPm1oHCF z$;c?kfZV4!qg8PDPTs0PhfekDRtIzTz@4iY+z_FnA|h)eszifq`5JL?bn&uSR`TXj zoU}Il8Tj|tRuEEjartBl&s02$*w=nPU6O<7K9mj4)&t3n&!pTRuWc>gP43)_npUd! zY%eA>885Pq9kzc7T{e&WDR%p^!z1H&)D?oRT%#R5e^nuirvS=9gW65eGidT~y*Vfb zRH52A9-G2r{-o#>`lEc+j>dvcaP5z1ovpPGOEzCoaSHQCCT{mj>=n0}wn|C)27d)@ zs!}GF8jAO58AN0NQC%;|XXRyb_q_{xh8>j-EAmj|1=S=MN}(pt*aoN?Fg@YXMfdQD zo+3*6G?4SSMok48FuRMr{l~PwM?1($T2t8f6QovU-Pr?zTy0GU02V;IWM*TqI0dLDDh#y# zvJ5=fT#4D$VYTrxdzZ=zTBzwJm(TNC{e-_z64#!`oI&k&fAb6j#D#iP<5V-yX03<4o#SGwxAL%?ad^?Mz5ohv)kSOxO_9{{H*&W$WX(oLCuLRHG?sKTPlRbaztCJT*Vd-%V1MiUwCf z{W{^Vj-;#sjWQCI`Kh1QpA^$Au~1d4pf~gZc>(sp4k-x*XbUw02Bjvaof?@H`9FUi z=5N4_+A_1$CO(N%fzQXjFDi){(KF^%$6>vCL?(Kik>B-A7wd zy4Y7JQH+vc_|8P!E#;o|K~x^P{NO2Ty{IqSu~B_!N>V$I27-Y;nl$J42hnDQ)|r)M z3|YNU5NF(gDyymu4pvZ^B_xUOTKND}yq4?dM9n>Ec0&YvZ1n)bor#tNYX6-Wgc=$3 z|CLSCwfOUU0kl>Ff&g@7dZycJ|39>Sg;!Nu_cmZ4B?3|+AcvGgNh&SUNOzayp}Ro| zK_nFE2I=nZ?(UTC?);Wly!ZVBe%~IBGX|cs_g-u5Ip>o>am}2qD0aUteRn(K<^u9w_#TBf?Sr`C z1@0W^DKfmrEA{z8-dA?N>Hq;Q;3DH5v9Sj+(y;*n_ZS?za8yjXiza!O{3~1%|Hxh+ zY9?Z;r-fhTQurh!7My++<-JonVC)h*YMLlrjZew4|6M0@2@_4oupR?A<$R^iIkF>P z0Aksb>Iy3G>0+l(Xg^O`t$j(3)ti$DsLdGIyQQV13~$oLeM74K4S2f(kZ*nk7`M@g ztv5U&qIx3hd`!Ar@RF7%Sa9hHR#q*MZy>5DOD+%2ETudVp%>)@9>g-U$6by=JJ9SO zJ#NMWvr3}bW5&BYurGo4xt2Z$Zx=KO3cmN7Y zfquk%XSMS(f5VFEp`(>>x(#wkd_l2VXv&);#eYn;l-W@zq{}Mu3+UqVGrhsu$}iLJ z8EP;VZ<*F3ZKKSU7fT8Fcwie6-od2PjGIWYUuQh`dEUXzeto8 zmVZloT(d$ZpdY>Yl=ayO7n5${?Kdlzwqb8CCDigZQYI~^NS5j|8G`Sj%p-a}SAydq zTB=7l+kb(G<3Wc-#6$D=u}1d5e z7hLMkT;ZsR+^Qsg?vW+ZeB>42!V&A$$KreBuTCWuN<1z@q|mOz zv_k&$0Vh@*M~0gV~je~qtD0I3@kf+1~z*EhH^mxZ{4^xs72IaQrQI)+pyCk8|~ji>8W(5JBw z{&q%Nv-5&~yYLFjw{5zHpzd>)O|R@%sUxk-PaGI{Rd8^I#As-up(>^j4F8t1hTpLv zI5-RQg@&fHK#5MzOB-^5GGaY1i1fJDK>xR|QfKopPl_j6@Y6}Dr8hc_p&J`2e*DvY zHDzKTA1CgtDK!>ZZ?Rjb_09NH@z2{lj$68ySq_HXPwd&kFVjznQRY8(ffQKi;`ot4RRF1omiNl zX}nn@Q4zw`mlgPSQW?YE0x1_|oZNklYIJO&Echh!b-upO2~7pcZiwp%7xo>wVhJa$ zZvUARTn?p$HJhvDHwrHCkgz1^%h8cBMg$UFBXSFCTnDuIj^$RmCuY1J4=mojm0rJu z)?UJHkpdL5%#RNDUEJ5jekI-<2;p6&G9 z9(1!&pwYZKnie_=5Y<~ukhcEO*(!_ZcCfRWYR==zYBnls{uK|iTKCXE&0jP}<8r1H zP1Qa;RPotk0?gO*Rej->uQWwfS|dKk!>rUn*jFem&~VcK&nOP3u8eE<`9Bk!-@y}{ACR0M=o`1KpLtP_@B-3V9>;OK?=8@x2U2&3 zD1z+lilNZFY4@|E<)6jtk~A8p%eP#dmd!Il4knXl7Z1H-#l?ulUKvg2!)fMe0cKd|* z@QO$yJzH^^&XV-;u5)j4@s$kmNQO_oU@fMr8=;DMdFo}`F5M|yb*{AYhs5=vFtK-o*`IldJ!%3O;kbd&sekcy--Tih}yl zNO-)7b4v~`#>Op>y;u8wJ^^ig%@*_$F@cWj6UrASU<~|EUr~8DU#>W7Hfsp8hKjJc z-wyQe@it+65$p6W%XxUapLKeKf{Ff|o5{Rka2%!M@`jtmcCg*9hVqZ-tkD69?{7#p z096(Oe8229ulg+dDB$Dktj-s((sU0{#8^!xj@`}aUL-+{BG>UJh<0i&jy5c>W_%sh{j6FJL}JCNJX(6HTeLIR64v z&fJ3%Lr;JCy7ux3M$X0j2*o-!#>ABJ+;t^Z>*M`>J4sOy9%5_!zm}#L4S~kNf_aQd zILGOUBIcL-H8VRi+o{)|C9np9ed~!NPQ)Mk@i%^^3_Lq&n+tr-)AvBB>g4(LK~;R3 zI8m$(`SnJL3FO(+Etsz9`Q+e83;D9p>6MoDjtIZV%k@JIr7&O)Ru9iOYejQLgU)wU z>5on~F84Qx7wA(=G)^&awOkJ-f(mfIQ}8fzGH&n*=mpdsPAiA-+&UngIt_jeml?_< z2xGHSS}P@1EdF2=7n%|oZVOUc-xm}O-a7Pu- z>~B5q<~dJVzNRNn#gv}o=8EQ;EDJ)xQ15A1$QZs*ma94;IwtPlU0&4(Gt^T!0k;}PkUjruiUNm756f|H!#V6|jH2 z%h)6QOH2JOjg2|qht_REPYtlL2M-N|papF3= znLev^OyuB$&BUbo?I zE~M!yybv^lLU*LZ9z8(tQ&O8t=r=6sv76^34@f;vE<-`tU)*<49Aq@!+#tu?UvEFF zbyX`zd{kLg@iWC>88__pmaWWwbAXAiq=K!jg_W)vesP<}JP*OtD7K zA@LPDj}U)AK;~98pEsWfzrRKdM?#%5%I2SlDx4t#7;0pPUWx6oo?d7V5p7=jf=0v~ z$OVpjzGZrGVp$OudDzgo8%`5LkAviXy{P3R7+4bOZSEe#I@VFRbso_rU4Rs+>{xLw z00ygSTbbe=Y0pyzq^8{eB_uH+w^n;8|H)4O9tjC}B$R#4kB?4svorCZ<0NR(7*T`| z^oSV8baqC`ezUwN+cBdPr~47BT3?f2R&ug4w%VZ_Qqkgfx2mSU4YHaO`wDK@Q=D)X zXJ)p#2wy;NEv4E=+v@phN9@)rvD%(A+7j4jPnCPZ03UhjbS%@9@jfR>ipqfNxV2(U zOeUkbDF?I~EeR+Q(39_}>0^HKs-5R0ITr=DbdC(xKzd@L+T~uuTON~1$159DHhfwL zGcV{&(dJ^=!9(2Zn@ExpndY}vrjDrTcD*>2XLa1jeI0@o-EaO=uRMz@O*(~f>u$UW zpTT$^KDdkxs(6lXE1fkmLzEE1iS>>j!xLfWX(#ih+L2zU==GvvS5_u$#Mm}ogX`gE z^rg7XF;>mIIX7Sn=JP#Fd9r_qlJg(6-f~O!wHuAR_(gIvXMV-S&YDo@Dh^0qkw{;LTyerj|;me^zI>Jpm!+J^R|q%H9= zlvN^TR{!Jzz$I)pd_Z)taIkvQ*M0T&Dx{3gAd*QJA-=DRo@S!@I*;PBL|gy|Hohdk zWS{HL<$D`8l|ek0AKO+#6%~|K+CihjjST{cIKQ4A^UHU5Sl{z72g;=})SO0Gr&*N- z_We2(bG^OKM96e<)hSqz9v@|7XHK~96)VvoADxcIGg@v*LB*KBQVeVy+vAmB^YbMJ z*=s_+_(_^rQQgQOV;rAuINvJ%0csv}51I1k8@|;Y))t5v#%1{rCE>2Ros6vL?Yhe= zD<3xNhHPzT!;Cj?$E}yHu{V{%Vz1ZkH;IUxHZSfAq9SoSFdy{XUM7iLh7?h{Uo^l> zsRjpT!PXX6ch+6Kj=)`4nYyE*znQA}t$|hPL%v%ulZ&v$hhc$G6|nD_m-k9jhq|2A zDQdVJH;GT(WTt03u5=h_#JRpFTNpg({P_cBTK+9JXBw|XtO1PU$%Z^vnl; zXc)EjP*Ip1_3h$f1LR?diP2M97z6FvW%rdxeeSy-tvglPUcWGC9WF2~x!7KYf%M{n z)p|b)A;;=b@uisSH7~E9Sp=m!Zs*5AxV06Hy`z=YNnb9DHI*3*nuF&@XYXvCjpL(y zIA32pALYuWipZhX-K)*L<)2y0%kp73R>bC~Mj=Ay4pjsvN2lATOIt5V%E+adQUh}` za>_~zUnGTV-mdME8q}SetOF-~og<%y;PqC)bam3^#CgL#SE6SeKkF1V!-*bG4}5M# zJvrD~+*tVVdSweAOOCq4J2J-i!ugo1^t264)lP2}2N+s<9v+VRvCYePH}t*31$J~a zbmov~C&!bD%UEtkqaS<&hB^?lgQI%**&QIt{5b;>&)AMmiiqN0SzfJ?0O#NhakP}2 zT5Dl;O-^WX@=QVkeADM?haZV0p)DarY17vm{LKvAUzYvT#PMrjeFICwsNP8hX+`gD z-OPrFi3p!9Irk0y5!3_%9i=c9JG6cX==EY@mmkMXcQmWsFwS|8Qc+GJ54bny)~Oo= zpw%*EuS-VaTSuEdbnz4V&pRSnBcbw8nkCGFVDl{%xCB>bs*w^jYG-I<0Sdf_ovd;e z8AQx+ijCxfM__7sk_H{N2)U_yGY;MZeJ%Lnx``m}qaO6S+WopdMVH!!*2zzL6Tmb3 zK8Wn2t)a|-xPC{b^JW33+|Yfu=6a#ovMDh33&@|#%gq3mf^{@3my58%_ET%_@Ak3a zNCFhAK~8&$x8Wc3m&K!tsDPW{9IYtD>BK)CW$tcgZ+*U0BalNVo;GAURS>T?v7d}M zHKBq}G+C<4gV7WDW+1;HA^m90?-2BT&=vH;FKuh4jPtLEiB|kMVA!+7s7V|Iv;ZK6 zV*w%_?3~^1#eEu@?rN^fqxF$@b4nm>b9Y_@5_R7G+K=Dl^=td~Mm27|M&Hrd7k`#= z`TAEo_ic#B38X=p`sKHkTByt6j?p<$sDdKLc=ft?TZ`kS^}4jf(T2jeFV6OGmf0`yvOS zFmb^!QE~%x)AjW&UVh*jROES4@Oiq_XJ?8O-r4Xrbd>v^2h0!AHtoD7zTqrk{=b1}~;vAKyHzDBzoh5Aje zKmSj;%t9*^yDfG+!tlMdkURmRNeGVrqyp#Na-oA!?d7HCW{^T)P!v}&bjnMBDNKnN zfFrrsSCy=bg-UP?y^)CVQ{NkY@9J!$hS0>IO>)EkbHEsv#;GpxEk0-e>M3n}$4`n2 zGhHDcdga%eX+!vdFLC_iVz>lu?Rvi=C8u(R#L8OC??vVoxBh8ApMV9ltzSk+BIfqG zuSqda59Q2-zHe5Nll3KY`&jytL*} zrD!QAs|bGc#} ziwg?2aD0P*gQj%t{b5oJ@PQ_ReZZ$H`><1Etr_1`L!)QhO%-Lj5(@H z#HenYz1XJ18U?re*TqxpaPj!6v)?EaIOo6%Un}>z`m_+M%0rnn7M_!vnHw72`4n02 zi-Plo+7+>LVBC0FVh4q2kFgXL@Zm^9MSI)SYGZx~@&h2lUu@b|yUTV%hlzCVQp=b4 zwI=xLGXAr@$&HDj!wU>@&5BaJ%Q*1#L4A4FurYjm-hi>J3==Tyft&Hl-<_c#ioWc$7X!(H3QhFAd>}`{qEr|2LoxFP4BMCx#E6%ZCPW5h)4Ff2O(^( z)tAP_lik_X!^NA^kKAwf)#()t;NvCvfu|PdaIy`HeA6Z&Ao-lF*qeo!nab3J#{J5; zLUosDMc)eI^lQCsR_*A=w45M6ptSmG4>aUV8iS&5=MiWE-y0uvIQuugyUL;IDYQnW z=({l2mR?`C-nUkd@m;Httc97#o3#N5KE5NczY`61L) zG%TE5eQnl*<%!dx?25Q)ZyRd)$XQ(y z`gt|dE7rEG9+uuz(zDZPuV2Fa3`Y?2X7rMeeBa*iKgEh~ui=m4ckBKo&#))20X{~As0gCB~w2cb{!|~J_)&XHjDU>FA`sAC$n3>q1 z4LhM24aU0CjC!RdQ=(EBsJ7Goz!;LbG5IM;MhH;o<;$(DE`a;9`b!JFQemMe#LBL)YUstA3U7dpvNYtC_hC)PBZmh19FRJ@MJeO+|2ga+_eG(R|)b%fhn zVPV%rE+ezO`hc#(SYE-k?7D{rSV!R8VPVAg6%{fZ9`8pm zYTqA({+Tf;A0YM7UATjMZP*o3WC(UDat923g~>1ndfLWWn5m)&f63@+zH>&ptrHz}$o?P^hu>6%&1>Y^{9c@Ri| zLe=daz8PC?l`q~>e3TpDL-xT&z|X8>_-KsVMR@P`ykBF%U@(!ghf2UhXjOmT+eJTG zhD!XrS^W6I>H1_DS_`v6s>m>D8tdAX^VnN)(M!iIWh30 zcN!XIefn07N-ATjA?Ti_)fMu#z}+tA=Km2HJGb=+R(q|SVdA3u>xvv@)tWY~w{^zA z!}f?@Zp-^a*(_Jk?p*tJZpm%``shQY_}ufCY;{xzdkQ|<#q2pS^frQ^DN;xhk%S$; zoVq&4;WtZKM-?3cV8wCkYQE@A0PJ;ByxoVc=*r_}<5jdsNLh#>-JIY70wD3+xb81W zMuut>(J7sCT`iP6q+YysWF4L82-J?)lj6C4`HmF9eae=BnlABYaZ(3!C2M|Bi4LBR z-UQ&dXLHN4@p_HJHG)8-644Q)>a4gnMetc?LDxHo(rHF6R5-7fm}NhYdgH_g{^p zgSb`WV`cZ&nsgOh_l)f{e(lQ+h=sYHboqqZzagh-4$_WjPxt3%wlYkwRX3w1SlcBe z440Bt-riZ!fdxavV_zRMNsi&-gwnZKx1{cJS9ekD(`swe5%^{=|M{t4fEOpySl+92{4PdD5GJwEqZ1X8P)IdC?B0bu^D60`eC^%&2kaS>nQ zb2cXnb=M{z+A7TNxs^vYOJUU&M>Z}XAhgYaPNQ_xa46Zcx4ld96dAZ^i}V!c=Qp~N zL9@76GHfR2x*la!a@2kSk_%yVGeN*2`L3yTFMA4+-n*_Tv_dZ19-o_h8%FE6vrJAL z|HeI0ZCw{zapm)@`19}*KwOeE#mRAuG%x)#@3*tr~^w#1v?DMb%#8`H@%}Kzv z5fp6?s5966Ej@@zh0DOa)W=t3Ng4($k!&`BjllHt z+N?dB`|6lS$c4}1FkRi28ena2jg9<3fLsspalF~PWpkPsp3@3@;QV$mbOSUF7%VZ- zE)Wr@Sgl7wu|AK944Gu0_@g_&QJjbSbnSi6(~rU-p)cPzAy2XHu=jukHfpL`WJX}$ z-q&{6mNyp?-C16iHLKZOSUG1$c(Ik$b1_umwH)AmC>a~zUlaWNl^q=hfF_=NS6{w# z?W7I+zJ(9s=F|tf&kFY`>lh=zjtT+6ic(i2r*-W=DWJ2eOG}1A{4`>~ljc#X$o*NY zb2_Wr$HTG+NUB_Osce;^ePE|sjzT`1VB-KzkHvUY{>^r}ldY zZZEHZUF@W1dq#wb-QoKVycWRU z`cg4&=r{_+Bd2n2>>4cV}>k{R-PnAd>_Ro4d@Gv@=~V1hb> zUWpSBY`eI@rPJ)_qCc?N-Cm#F%m}|R-<_jWtgS2WIH;eHtg$@$Fbr5ir|ZyL3tA8b zG3e85@1fVnvs(X!_ll~%5xf*f@_4Ny-RGfzLwcQC+To|9!EzK4g)kh?CIs++!G3t| za^qF-?ua;9G=f)N#rlC33^R;A25`VEy00a3UKz+r>KF;Rm*8XZzfmxl;5Cp|lco><@)MKO zB?GWcBSs*!b;1I|#owfHg;3EPtp{Z7SQT90>vD5Rs80@VU5~^)`5d)Y%ih9Y2dL}f z0<=U;rTc^9~F+xP3 zPG;0(7WFAbb(E<;(^3pZhKG$x*a--_!0rKGs{z5d!MUTd2p@qT@A$VZo}W8hoI56+aL5a|I}ui|##ZbFp|-Sr3wfE!BFbUR%SpQELFStnKo!Z)g=yCNbzhT;CHshV?z zu6-Rv1>20z{+z{&3m8BTIqKuMdz64_mR6k80?g*0BjM#=@(1n@Ay^*WDkv{vILdavZ(ghT3fOzJ7?3*^$3P(BmE;0Mah(WgI+0il2_6Oxs za>e-DM#fVQKTWr)k^F+p%2DW<_T1h&vbRj~h=TkW>tzs1S76*%EA)sp{MQ{8L%9IN z+o4CC-E`WT-m2XVjOaC|_YJZV^ zpg=9W6%~EZyS8@_eKaa6ij!M+feR&q-Dnt~I6xU(c7A%pIUA7R*!K|&_`SfKw>I-* z;@etoETTkn*`Fk10f!%UwD%UopiYR{uR+xN(*4$_ZBbqH{mjKCH+b2XB2ITjJo;VM z-8?)XU(qF`0fVO3bFVx&sbHU5=xlB17`XJnthikol?aoOL1HD?U^m~f3~n3^=Qq0) z#zxp^lwaS1oyN~sZ{oH(f2;l%6RdWs)NVwT|LA5g9JS3wdmoYGmc8({KQipT5_$lk z2~0OKTGM93WE#ZS)E0Z|0-p@%4iQ5jFqr>KEJ0@H+`2rzsrS1Z{?TIsq zGTBqQ>NnAT+7Ze_#OxMl-4lnnQG|BFXZ5k}gc+H+rXpwnzV?0v!f<-n$8ll}uVCgT zEobrxw6z66WBy@)W&tfZ=lTTj4X5F?N1X&v?yDnbZy9`N5+lHMea+BS1#h$b=iqGv z4q!q?_7;LSUk5`s+-|M?Mu^SwGZ2M~x-Q*JsC~sz7VL}cFRC1-hvx&6gE1>lJaBx_ z0=ybk`BsopFy?mK)j(>{1{-xqwWB_>mT7FJQ zP+g9Cs_^GPO4{NcNdpMw?~c2!d8ZLyF7}b~Gh0tO>=?ElPn)}6Ir^Baf#)OuHB=_Q7Aj`NBbR=DUcml)usFFeO8irSMCUx41~A;M@TYP69b5Ta-{Xo z^rTF1@3^Y_r6;5hPpi#t#jOFBH4nt2aWIOnVhL0$C{xs6rsTRp-W=@XsVN3EH7;l~ z)3vc!zzw!P7}M`#WC`8SbiWM^_S(janAF;VOfGJF4n__A}hy`YPoI zecepuN>w~4N{x?Xo5`1SNWYD*%SJ;x=;?r7EuUU*vhKXLI4f4oBckWx@b`78o>8RJ zq+tuqohn~!QRJDv*qklH;mL7l*6srBRvpVLzBim~(-xnHa{+U5S#j#NUNNE8HU8}v zRvQ{n36wdti~(a+Rir;-vJq6S@Sbk!WM}cwQ`Q}Wqb;;Mgf~FkTwml1P!~RxQBjaq zlbLOrNSp}BeGTOKfj2oPci!ianvD0x4#Fui2Fp(wP>0eK(Yfnr9WBAul(=}S3-aag zO2q1xbc1hyL#nswsmAOt!x4ptun~EGJMUfT|K?Rh<@;H*xRqlVKup zO%_`)o_CgzQudCGL2strL155eRb1$=Cqz4tE~6yiu?*t?ioAs4psbJrsFTHifl3=S8fkrn*@-o;ubYlr zJ*|M%>dbs5hQg$)a02&VsB+C0ILe_%*+m24hbUsl0WRF;9^q5-Qgu5a8W{h^Cp`j< z7U`X>5B!hKQz*XA!{xpGcrC6cL#m+n^gln29YRdcMps{0%z%P&fOND;iD@@PmKHtB zdN)O0M7I91!^z|77q@QL7nO{tx?5IX|4X&y6(YkyTJ6O%-=b!}A6M`ZA z>W2V9*cKlds2CkO4Zkm9GE@ZTE^fi@Ji3a*1OH9Vffw1otFMKEEcJt- zEqg9GBLzu+Us1HVrLE+@!%k3z$_zh;3isvYQA-a;A`94II;Q7z@kSH5q|VrB0Gq{B zHSeOPGgfI8QWQjPD zYdN@$N}Nzwc?-rWDJ!LBj}gllj`RuL)Y^A5U3YI!x=7OpX#lvIKO`Uh?=pMjCcjN) z#CU{%K}j>1&mJI=HP>k@G}_|nm~oMe;B%U!*!;=uSD4Pru_WDzj)KaUnV1@f8UpxlRlPyAGJ>f zV$J+&D8CX@z*JecCQz?OfWk~)0S7_(gHh@Y|0~UW$F;?#=hLV{i?6zs4Eo;M4rJgv2F$?p6cjw}D4Wa?S9Op_@~Yr$fZl>>*z%rYsaR#Mp0r zhXhM6$WPyD#TMYx+^xkRsby7IF|9^3eD$Albhv!C$ZphHp6A^pqIX7jyy(umz(LY1 zXLJ~R@|hdMd2EBg5w*#{;bPpsZ%@UFwzh$WHc@G9d*aebLfgVaQ7JgckKd)v9)Z>q zF6&wzWSh(tA2z1^06Z&H%sQwx_crn{|JK8;HTSQ(25rx#y(Qf&yvd0V9qN_7{pXgh z(+^P1hx#XgRD`t$2gLRqT^+Q~x%V^ZYbQGQ9Fm1lH`cfEo#zVnhj6YccFPOI?);H8eSs^ID19HCGq{m4wx5y+`s{p5zh&)nJ}8?UNP)yMH8p0#)4 zPy$!Rctn#dcCc>Se0`p##ZIyHBWFr%i~4(z=zuQyJkfl8d*eCLhNgmsvW(Mi#^1C> z3;T*=`HQi4TbIm6>oEOADi+eD>I-AnWM9fh1-2wfNL)4DUYM?zN$E32Be$F~{lPvX zw1GEW7K&$WPKd=*T)a?{dSe67OB7i^7r^3LZ@r1>D@a8~ckk$=ulL=)XM?8KsmhuH zNs`fvt!T zrkh*7Dv4Po8tw%G<3sMXZ8E!vuIU0>{?xLYr+Jv06M&83y5BPjUKlLN(ocV-r7@ZEh12zROy@==(!#{o zGdg5A`)zWiQk;7RWX@s}3g2`KvB~uP{ItbzZ=kH`4HIRUqFm0EmF`;mn~+xYt{(dL zwcq!*SG)Az6T-Fz?+igD0wNaEHCZ;wvLlo8rS)O{>x>cd<7c7oUsy4Y=Vc1!R4d4i zj&=L^s?NFU{LKYuc4@$cXQF(5Bi)cUU{57IiqAdK|Kwdav;V*^yPBH-=Z`pi9wRq# z4klJAPNRV{pWWtMg_B1>5=ucKL)EDfZSv^a&I}S9`@L8RA^vByUAlX&X#E0)wlu$Z zU>>>N(QY4iVIF}L!>!~Qg~84>Y?O`}a}iRiiq`{zIr@qUjnlPnYZU@1ToU=RRwm2z zn(GJ{0-a~N25EjC6x=_&4sHBYr72ebr8=uE&}MTk!~N+V-o{o};}fltqT!>>qSZQ* zZ@#xzdI58AeXj2DDn({f*=e&8Amwj_xE5@VFk2IWfF$*e`yP*zaufGwW8o5H8{9|X z)X{76;Mt?z;?%H7F`rf@6=fEEq^Z18!!?qj^ z9A2bE8c2WrA=bk8cJ)T=5!~8K`KX5}lbzko2PZf9aW>&ZW;>;Mg-w(KZp$E3&@lJ|GoiOG4-HTTe|4LR8|w8VGFnakWH- z=!hti({%ny8#%}7-t6G_y(NvkAc6|7KKmUK zBA#JrqqR_o_jm<=(uVoNHek}JB(qR~X4pw}=6MW>6-x;wl2`K+46f(YEEtExTi0)8 zALUQVIyz!w>_?;~W($+OfWQ-L{;l`ge$hO-OjtKStj3;gE>c?o4b!L*ETK4O#(0Ig z9v+`zJ1M^YDtb`Y0acgIDm2tqy_$fX8eaMt7>H-U*tC(>tS)8?*a)rNP*hKkNCWVY3@q}~GHqsr75s1}T%<g@mO>)LQo=K90oEbT1R%&OreeLCCGY(`~c}%2bpU zG(gnD4`gYiy~o=J5Em3e{kc`brqTvar%%Dn>-FF-V+rQNuh=nR1`%zcCi^uAXlDnTr^YJt?xJfQf{0T#WM`ISlpS32Dv7i{A=WQAANyRpAC; zc-=0ynzB48CepPF=Wlil@F{pMBX`?Van|bfm6)!-{n(g7=iyU|P=5Ze5~R6d;m)vT z7EvRNt|1Gq5en=BQFL0(lpok@XSbYaYNVM%;~GVUPiamFK0tB(yuKEN*0-XHjk4q1 zSK2n0%HK`U(uC+J<0RgtV%FCgPfGRqsJDq3yJY@6K`u}rHCfv9vZ^)GjHDH-EY2K! zm>NhPD@=J@oNrkaEC(&Y{n7=?km!>2w;ha==^BVrSVWA9@ZE#Dw0XmAYI+_0DV&3w zpIcepVKASGUUOZy=Y#dOVyb#gnHB{Rj za0*oezuf;?BtLZY?uFJC?cU5x#k6G}>4142KOd{R89) z;6CW%0!m68pJ!Z-g2j$ji}}vUPf#5KVJEw7ErQT8Vx6bRUpSSqY*prQ>#_XszC{?A zlCEv$W@QgAeJD6S=5KEQfDo>6rQyn;h?3xHWLpVUEQ07_9w3YQw$F|l5BI;&#eEF-MuC={IpH=|>-SkHlPbn?hJH3e{&*-*L9!&Dhw83`NcTBq-ynIbwFyGv zGSrxDyr^D(br^q-IPdl{G;>_+rSyIWOUdy?s~2=k@G%;n#I)(1Q|MtPq^OuCA|_2q zX-wCeuR^$g{#B~om;B<)Po8=rDwCiA=N(NK<49<>N1kkkpRb&XsQ_u8TyOh(^T$eA zocTf_KVCSR(De-uzqWtSe=e^u(yCF3>HJb4LvEdbmUgM(Mxz0KRyePc>CR>B56J)G z(;6ZqZz=tAV1v3-xnx7117l?Gr%dbc4^z4I%q(BC$gq~ok!+QBSMC%_>l?<7{1>SH zzRYiwzlhI?#k=}ph!M5Vz}dV6<&u-cc80|&uKxZ-t`5F=;X?F8VQY5Ie;-Lze<%){ z$y_#+o)V&vGMJ{wR#55VBtwlyI7LcrkvIOw>NZL&%dfawfT#S6I8Weuno4`_R-O4hq>`_ z7oLEqk%DI2HBV|lzntjDwQTrS=!HW|OMMDTUztd2is0z~M46tRDsb>HgFu=UDMGy- zpyd|_6I9`>Dc!+)Ubgx|v^ukE${L6c%f^bc)uo0}3EBJ{r@R3}ujxJ5aCYW53_mmi z`B$spjN*?Wo&h1AJvEnaPdyPo{39~FV`&l+{VU7UgFlB@ha}VbGRFx-aKy^Elk+22 zu?x$@R|fTe&Xsawz@_JxU%ekD2YJ%5m>?(n_u1g&F9DS~ITJjq1WU5nfq2|vHoG;+ zEK#Y+=_4lc;Sjdu7^VZmAjG#L9g6E|Nr2h?IxhGu44M@In(v3A1zY$hyFfTrc#Z_0ZC9 zhyqw`>He30rIao(C)Eb{AteZPvqRwEQU8hxcTr8Q2L~hEi0E z^l5@Elw-rAn*@3?9lUdH+Hbi9X;9h!25$OW3eb*wiocMd$~t#}!YOfF)Y)2({0TV! zKJ&>&22PZ&QAOl_0ifkqSC?-~8a7l^Vg7sb@HgB<2o0ul^l-Euf3@v%*>#udqIQ4X9sz7ZVn>%K>_Dl4`Zm0~>bDOEj&iycZ&d@M1Egx^(Z5nfd9<$>%ahg92V22wT8vc9e}7}ZH+%g+g`%88`+7}S zrydyE|D8917T&t9_2#I1%jgJ;yV{EnON|71;$(%ZxWS_i0pH|x*3Ns1&X58eVQEpk0i%6oK)Li7%0_M6chTetJb2=2^-VaaEPfmd`3sAzdVf(vQ9Iw8KQb=H<;U|iS4Xmu>kH|Ma z(baF*U8mk!?P>4%s!q(?rlSKHNK*uWF7aN?wJYa+gj&n2PiDW127ezm9GpznpsxOb zt}%8!mqSIBigHwB=g)T!&mB)}TYR>)XzuO>$}#{8P|fUKDNM#}N76sD|5g2;D}WC? z5F9ruZyDJR)_5!Af&Z6wn~@ff7nf6$nO+_S`j`CqAwkvi|A|)j*L+yT7HA<)`Kdkz z%t{NOV4mT;0GYQQfhd}>ch{czq>v^i(ew4I2o)9e5Zi$xCwf^Nbtp1Cj?IU&jk|=W zwTs>sia3Aq?A(l@GU4W=7w&mLOPMOV0bnh@|Cb~6$fd8#my6d>j_3mH^{3yy<~PeM z1VfU4sStHdeqVFGP8LeUXmCE@OF#^792>qE;lE4tbu_G`q@{9b@hIh;zw>?i zu??9gtL%B59>n7Q`|IAETn)=BldH5j6DSQ`mA$!##3QjscYu~XE9v`}HI$C<@3~e( zd0+w(@yw0m0`SE=|BfdXRqTf)*RF{TpS}W-bI~^v8P-fCNNYq2{1xnn{6#pA2wAZPBmki+w( z(|e!Hd(Y#3H4in>a_eVL%EEbyCZGHflzE1bj%t#!`5yGzV7>FzzK4QnJAZg+Ue|3x zb=3oZwqE6a@~GB(BJ$#?MYp5AF9bugLHY&U9A3uxD! zJYqadgornI_voaAWL$nslEO9JyZdct(WJ=(j?)Q~(r!OL<30pb=ezZ%%4qvAtD#ir zb{JTCBB7WprZRZn^KIUVRQ;ad-B1`Gvy`nY;h51=gTDNMqV_=#{OU?-1l;knu$B#{K%GU%?_Fe3<`Ro|i0i984o^Q}4DDJuvQM%_9U82QxU1lZ33D zhm>}@0THBF@ zLSaOW7gcm4G;7=3)YGd(SUBx}wsn}m;6#nbYESJ6iY$Lf`_{kho`k5m1+zB}T!zo+ z&)3P+4>){FLB%2WWcC}w%bkNy%i6hpv-AwSix;JZ|NGXR?cF`+Oii6aCPq5X)~NC+ z3Grz2i+~Zw57)q8$XwwS1ULuWOkQ?-%URQl3KIBV)yKQIoWOBv{uAkpzVD(0zNu6D z47hg;NpcH{VoeVPn7`;>ctPZL9^6TbX~*nGX%vjd8K|hcwIAlRw1ud9J%ZFj&R2&a z15YCR6~vzHZ*~hCE0;HXb7a6+G~sHUaKC9{WxM*f+{IiU<`CY##^&ke(JU1n?f>OX z(ya#0{|2AJ($ljm9{{=4UiIB}Ze&f87qiW?nA&7YI!U?K{xdz&56CmVWf%2*)t8MR z;pMZjl71R~`(hLMf8WXc>KB*r^<+LFwEemYPfqr=D+hU^cYP!O1Mex`Gi5h}U#zZv zFhq0N_oDj-sE>m{xY)oZ4J*qVS$fsR$+dDi^AOx zpQv+*{I%JeL7GzXky91qy1l6+kCu;*%G`JZxq9KhmV95`;I?h&oQ%>Ooa~!tt9bx5 z(bjNy0{Qq~p&*xYMWsu@c&)M5FPgHJ6}F>8PgC=3dHK&gONYfehHs{-@aK#hF0E{E zFWRA&YkQxtF&OGdJ6aiqNH?YQG(1lC_8t23w;VRR2keKCP|dRk?VSVkd}eE1YweTz zG(r1#Ml8gJ?m29>H6U7juvt1hp4ZmM#Qnz@@df4gMi*k(I`ivJP7C+NP(M5n-Da3F zt3RuYJsmYGrA~`IU2pQ}@ssyVOGJFUC2fCm?~NA`Bu|B@VGOz7u)Z&fw3K@vn{%w4 zlls)Fb?(!@FMp;a=w90T7^|zJ2r0KT;ECHq{FG!psi%v9cDD&{t7hWG(T2@8@9d~~ zU4}eSd)T0}jm;+IKke)7Jw9Z{2<~@zTt(LHulOy!qHFHoCXZ*2i9XZC#?~WCrq--a zcejiq2Z)vGc<~L>%Fh*!Lioh8b((|I@b=gs0UTE@7FgSPF11?6qClMFlb$bj%M)9A zlPuM3AvatUyAoO@9<#kG%#6m%wz#O;CD%Cf$Ko`#N{c1QB=h^alEpq3MqZ!_zIb6c z&|WNBkY)L@lB)x^h;uI{hAqKq%LngCo5EVGP%7p8=5lYFzLA-$Wc0lmdIpxL02yci zu~Ko;Q>N1yPW+>{tdpUl<2PLGyI8F-OO9kHlpb?dt3v0iy`6Vz4t$UoNk;Z`&Jm`E zs~Elww#kvcQJ)( z{!)Q3jr&5ko4DZcN`#ctxe+uZNe~kBcwqk~4?-eBArvIga6qSnifMPSER zdz3x}tL}B+8s$4}=crQ)`~yYIvkrX@f#2xvZi}+fXe1l%UkyK5+gTnPDJZs|zOJ}- zf$JiR_q7=5jQD6#kMXdXvR(nHQC57O5RmWgd|R0XznmY@(p)qx8_ zAHH>AI4oWCc4+sao}*KJz8qQi;bJf$>m&)TbzBx*W^%uZe1Hq4Ow>t|`uIFGfr3Yr z)qm%P?+nSEA6;ghFT(3d9#Fk&;w8VfzpwxK&zFa$!ub7kb(9hVhyBQ&^jMl-7ISfQ zH1qt>XQ2#sHho;<1q~?nTTjtO8%?d{=#}7TLr$O7KJSBesl3P4%8}`2&B0 zyH_Dd7AnKT19z)u4`}T-$Z-bS_pY^(qm75Dt+dX`LCIU-WDVaa3()WHyi1LVu0+iF zmd4`k`Ymctiz!(jML=j+7bk%pfFGD>2P7JFI|3awe7%gdM!1Aoh-kvh0D}20vHEhqDyxT*~M&Q zMr{O#t*lfFW~lLF&TI+ya8a~-4mg@^%Y>xb#)5X)h=dmx!0%tv3Kw@Tn;F4J$G0vg zqV)AtD?w~z=z=W!K4fvvs7;~wH6v*6%g029>NEw^#CNxX>oP&@m81MpdljB_KCwcH zW|Qk1j1EsO7f^%-bnP_fMn=|(pwy{uL5`FSiT4M3kb7mt!zokkrEK+EtsQbjb*pp7 z_=!;r0PRQjj&F{Rv^o7sDiGJJviX(725M4XgkE$rL>-OG71vF_ALV(9smCVFHD7NN zblR;F3>V)%s>70Uig@(4@8btIDHFI^jeJ{Av)7Un?xD@}A0Yvc!L}xiO{FCk-6Px= z@#u)iXU=Zr6tf*JFDU=?W2!+yJaI?^IAK}QeWqPb?{#$YlWe^{!tE{6j z&-PlfSz4yG&~}cXQxrgsAAmVDb$})PFv^DJ3UR*UCu#@Yfhv3_SfayPl`Igbkk> zX{~V0;JU|_l4|>o`;}Gr1<0l6|Apqhspe(l<72|%P9JVAllp$OPTOc0E32|`Yk!|m z699puw|j-u3Hru{9e(8TvCx_M3gOmJ!zYucx|O7BqhEBVR@Xg-80A1f40)3V9BtpI z%X&9q@hM-Mg8(nj&|SlL`GrICqJtT6P6hc;&0734g@~{hu2+u-SIuEombq`X*PBD+b|FxqzIrLd!zmkf)Z)flaXZXYISrk;(_AI4C3POPwYmMM zfxWQ%RU~?Ct#pko?E1|JFY1d8OW)r4n)2y_TQa*tN7xYid)9tLx>XqGt6GwuHjSHt zUDwqs@eVtL0VEFSBD9Xfk74b;SE`b0Ki3KdfB!Qiy>q4h_Xrj*Sd% z>?vgX)r>|8x(6&WK}w*;%#4exQV1Y}Ip77%Vk8myZX+wEwt}6UIg%evt~t>_z!j#1KNh;5kG0}T##BPV~UQf;s_ki6EM#ZQZROVFF)#NJ>++A28U8Ihpex_L5 zcgMm2-1nwcYmZ&{(6!^o5*c~5oE#Fu01TbvC#jqsf!!#}!z&0m!Z!)>W`u-QgPDKh zzW;u|gf}6~Mc;B}i>1U1*v9+pxOcU;`YC&9Q(dk5*B}{)86PkCg>`?%Rep?xHHJhI zc(5;mEGqP}LHrke@5&xRtZjRS=lrh2>h$juM}k0NE|mn_l%_4E{_abCAR=)FAA(FB zoT^pM_OPeA=yYR?Dz!aNwM!2Tjw|K=-H_k9{`uX$?EbA-|0H-?US3&6-{A7%4B5;| z{1ow^(Z(n^8fyf_bZz?scoV z>iW4q?eDaQA4j*DDW*=ie`jR)jXu=d-}``)EB=Yhtsb&)_SiY4o}s`kA#s4n0Y!o3Q78T!6Uz=)4qr*wU&$hxOV40c>&mfD&9Nonl&|EMJL^YET4%t+^6D2 z0Vtb}j|r*Po3P`lY$ruZo}>#4$s6Pw=_*s`T~j)^W|{pj%`^AxrB~8>>)@mp)G_sP zG=r=I_tz0U*S#QgQalx{6c7s5|Mp#bUt!lY%%hY_NwQS6aIBz-K4`1Oh=W>Q)(LgT z_A5DOhu?JBHLUkeQlXXSA^G zsL`N29AM^dcZHrIy4@9?dpXoO;v%0(lHx81h(Xw zecejGlOK^A_B4+rhwXSu++flf#b|4rO!w=8Bmq*9-_2~{wDe;~C5`IXnDE)@*fkQN zi{Jl83t#|4*5%bG7LPEuN+rYhau-vP62+8XnsFISgM*05zruD`hAf@C{JY`A6W zZiJcAr!Wz(zJfVF3-XUJi8^iw1usdD+6raDn(J}xsCL*3H~x`=c9yJbu&z8sX==!J&I8NgI6Pc!)~t%+IC~`dwo zXWiVOuzNHq)!^sx*|1GqoX}HDrc14hLwn;WfKNb!BnaqMYQ~dOXMH~S6Okd-5eYs$ zGtnh#AE2WCLuf39Ox!K}&I&G{xA#~JGpp%$qh|MOSY1-~+RPQ!N^7(8UVgK` z2$2?^-zO1wYxhppI4<7T$6VW zYcam(N5nT;S6tYqE#f2pfyl^B4q5h;<>8~|3e3i1AHNz8g)KsKzt*zA1WE^fvV8%< zY$2f^38d;^$;(Al~qMTI@GII+tta% z_G1QxAk$8k`9^0#Qu>Ast}%~ECRkmbU@XWa2VJXn#opH9HsQCNfy0wDqDNyqX}$5> zDHoXgIXYMy?i=n}UWObDU1ewxB9o2afY!|QXUSbHTYk8@vhmf_0mUA!hdimh*{eM@ zJzdG|*4Cs{Te}k^3_I8g<(_6H9d9q?inkSq)A&v|*u{dW$%puECmN$g$8Ss>Tt^*u5ue1{meI=oV$h!Hy=+KOsx#lW z=cShMb*E+;YgT>X`VUYRw--fafmssqWO>H$*h#ft;u-ZL2H6OP%~rYaBtH9^jHet= zC)&nWORX^;yWM9Z-WZJH!i=l7-SU!(kv!yulzEl*`d!H55Zd7e<;dmx%O^G^+0gDPsnnta)LozeK0~l!$r*gXK^7|MbEyEpmej4;5?hK{yq8}56h5~c*w2iC^NaL z(eyo%j~!_vN1h;NnaO-iYlz)W4#)T`3Fa4`u{pJ;3&uy%Xw)wMnci5)8(B?Wj&7s76F|94=z$x5TF{jOaf3aWxbWQ#}hh=9@_+ET6@G^{m!WSSqN;gVhmNNyh*oKda@M8%W0YlPm&IwE8f5kH(r@`zz_C=C# z;pe$=SWl7624b3@Jo(#NbYO69e6BtG$I3V@Uuv02N|~0vHfnT;npPMX%HT&yw9*EblrPyrF@` z%)saUHz%L-Gk334b)sEQf8-hEY`;@^S(|#Z%HV?UQVb|j6hh_%M zu7JO;XSt1y5!zoctJDHfqd0?X&B2M(N)O%-=@826adAG|)5=HatdPNW9p5^2n{vnZ z7(v~Rm-7u2cCHWaj&)#rub}l1(-}cw3zG)=lYO;D*gz!`@701O-Z8|$0_zbqq~Uw^ zKWeR0nAqL7zoOM$I`myB9Ne6IrrW(>WiuPgH#wls-~mKN*lb%bIo8v6vs)pb={@#6 z!FU)x9*|AoH)BVh9`}kDkB?!lt3wM}hv7?@nT1?8%c?l477eF;5OPsgc;>JFMqCju ze5|8h!ZXTm^1&^QAyc$)S2)pnKD936-|5a3;65+yuLFaZaguG?$th@!ZMD^edWOwt z(r_&Fbm)|=RFE5Zz?&A;=`P)@%kA^VJp?`y`uFx7gUh~HpS`p zpo?=!+wi`vY3Y}niPkp~t&fnufnC>VRuFBsya2!sTEpF)I1PB@_HP6I4L4poj+K)# zMfKWcfyb514HHFfi=g&SH#Cs1Ay=9z4B>+8|#$%UcWinJfC;}SI! z?EliKnLAkfK-~g~9{nAn?_+k?*D47ArJjYk4v`j{zr+T%WJ~dNFsl$|pk&M~D-*WT zJK)4hhBBC5kv6*`y~n{GPftCclI#M5PXr=4)ZBl#RI3sD4C>W4e}sQZe`?LY&zzZ3lc zpot;j*GGY^c0&~m=D(8qGhhKh`VIWkGa4(t_^#PUP5wkok<~Sor+J+{D)>?HRr#Ke zFqeaE0(N6uxF$)8U(UHDT1tf0!!g~%pzB`w__5BM+%v(w=0wL>&I;>wUSBU2AH*isL$;so}$88kcL+o|5DdL zbLE+~r52OSxR(5hz@|~>-3?<5mFBVEivl^bORHsBlr3)M7?y7WDkNL;E!_o>L#RbE z{lJhyLf!zp!ZTT8$Mf#nxwtWEiKNjb9;?7 zb5DzaTR`b?{U|Q33J(5tNZ_gJUCtoODz1%O`_RG^dDvMza_!m?D?Vv%c+7C!0Xx5l z%V2?Q_|{ebOKiy+!agg_5k972FEaY(Ps+$A97haJPsaiS6LcsEdBu;LlRI2-KpEE~ zKmsf|>wpm1I2pI9GXl2|l(2)=NF*Vx*FPqH&Di34Gzrxle0w)m&--*a$PNEm#NZ(B zf(;AXgt`zr&wP2c8V;vOtGfUf1^MCXEWCDp^SvD|3p0!Z>=oQ&x3#uW^!N!?H2Yyo zSsC*K=q*6JL6nn$QFe3d?UIT`!v^NblM%}2s4PV;8<{msMYboVu{9gyg2f|QjIDod zTyglpt-D}Ar8DGS7^}_y(lO=4!ApjjM`GsVt?W`zlxR;-AYy#5T{fPWctS4}wa-Fpb}{U!(Sn9XbUl{&UU&dx3kN{R! zY22Ko)&3tK8$tBQTyEOmyj#udF?jJD*OB0M)t;3IprIVNIiY627BoS1;D1B%9+CJJs4cmO0Im|F?{R#MwO*t~ zzuX%0!Kn!KGfQGK40IoIRmWU&zeeG(xDNXn8Z(q~1;VxR_>5q>f13>D-ZsmaHNTI7 zg)X!OPKqC)SJ3EflUK?O)Kz-l&cAYLQQ2t~Sd{H8H{Ajax|a^J+Nu-o6@eEaAsk?4 zfgyv+55|pSyv#;Yu*G8gX^Hu(#!B@^6XKJ}33#k$Yk&Jc1N9ytk`;7ShUn9d$$dSb zEyNAm)lpy{>%eTzwn^*3us(7eHGSw2F95aVa|-W&J+=o2B3$ab1A5~70iZ3Llcl#4 zZ{8Um6b=T|>f6Sj_tp9vUtCg8x((qHx;>~%kYk!vczQ4|r!f&-CU~|fKIyn?x_Cz! z?>9Qe4D@%W&xQpE!K{FdkgceQCwra%Z<{UQTVXohlQ4D!i!DNIHToR0ez@o@j-3&f zVLXqD+xc-A5|@Ngdh6vY2gCe);B+xS7o{YT+5aINtoObIdH0CqKyasjmA2?;>zG)? z<2+UsJIuyU#3zPkOQ#$y+daSQ#*DJ6fboSy5&W=2I2>-7ngitg{b?OD8i)5a_y&cB zy#YKNBux2X!|RZ6vBO~8FHlf1m6_`PqCudB;CB_6wrodrjr^(oo3f&m#sdU(Y=dm5ht6aZe0Lz_5G> zlnQrzR|}&lv6|5k^(}}m@i%YZ-HA>NcA1lNA2GLL6%u+s|9oX&BmDmwXjE_)|1dG| zKQUIh_Xs#jKx@07I^Ka$Fag_Qq79p#-;W?>uH9Pus51K~#b-G=sr!%>4U;D58!%3& zSlaR^%Xxfmu1mbJJ{w@2_PjtU#sV3DFe_t2uS`oWxb8U%{MG|P-m3CSagcd1y_C5@ z_o(q>zSRn~oODL#2ai!We7y-?ckrVW$3R_uKhbArZ5P3&(cT2z+Kt?~I%*V?m^c@v z`1X(7WSyk_8!zd!;_}S6-NY?Zx{3eG`hi^tfpG&zkv{}bxEueRsKTxj3Ti>k%SJ17 zLWv$RJe~@4@xZ$X3gyxko5|X~QsYEq-LQ6)38G-&dcOLr1p1t-mn~;hnCmehNS)st^A{@q$k>A>>`znH{aZ))4Xq(Y>?TlUl@3y~` z>r5|aQSxZ?TiB?R?hTW@ExEz(237tvhbiu;`q;M|a%EtM<^m9F0N@ya$;?@dG0~cW zcaTVtg|03=*gEp2VtY|uE;>o`9mbXw?}fP}*o1E)&W`JTjD ztIsZSGRVexX7(sr8zXlma$Cn`o<1;5RM>HDFZ~iy6tVFejd=F{7sn{iE-?>PJNdu- z(X~Awxd6O3-SfZ00rif4$gFBN&-sVC?0Ydxbe1M+;a~i}fNaUm`YxYab(vhux+Fn$ncD0$U9}fg zo#G4VJ~L~#McKZbN8xCjvUc`D z$;AoBSCww%)@!cq1DzgWg?Xi`L*%HUI{n#oVIuTp>AZ!Q%1{p}-J5%H-GT7W8&6oQ zE!Y2T?kT%*f#n*ubs;7aWmjx(aZl3k@-=tQpM-iiYh|jvzfnoehEa<`2px%PSC0F! z-5h0lCS)%A7j*M&tXGcCUXfy`B6Bs3$F(G6VZ|!8N#pv}$jH509~RA*jUKbz=cJnL;uNB!=Va( zaXq#1A5&45ve&tLJZ-wPv}4fT`@}6cO80T6#_0YY>)YkU>F0EQOGud*wwrW*HjyUg zEt8AR+LfIss87(sZE>noovFW)@QG}c;UDu%=82y(AF|AQnUTOl-2)*}DVzA=L?TbN z!bIgB+P-!tr<#YytC*=-st{eSFp6`LL6PI*#~N!s8INVO367P*`~)0d43}_B4h-ZN z+1m>reU?fK2Q-6st>|J;ocFkWY;wgrGe|gqwWW#uY3T~`#owrn|LZcYLR8-m*S~$^ zAM2=QHjaq#eg5$J@@4z45vJNgr`Lz;Fx(`PfAj8GwS^qdJG~_V*(DacOHO;H3B)e+ z(T#3~+O~XjemOE{y03-@d4E1QzFrBM7eLZJP-ZVXu4MOT|HAY3LvisovsJY(!bfO2 znv6iU=DJJQ*4upIqHv*r6(EuPuU-T60Bl#Gr%w*(0|XPd)gq0faS?TzfbHB*F;ZEK zp5fkg)#oq?5$D1MoRPBrrea{2@J; z(dyn)EHycKlc`*+K#-_t_DQ2Cq1c|0jQI#@Y*Hbfd1_fER%09AhN<>1Gb9)F1`{wI z=3I|qxUNk=hy6(zLzDp9i2KTryRSqvZ!Y2OgCb96BNu zR;CfP%kg?+!_nFr6I5`w@zhu^t4}Dne_G~d7Hp&Zo+QOlb-Wxosn3?NF$ECdMU-W| zlj+l!D}M9r51N^t{rQ5*bKrJ?gePlcSs%9d9+!X*one(d(@K#@aD5GrDDP>ylOiSM z{cLw(Fvaf;^}Jmdt>LlkX68~)Yh+POqZ7x}A0oam&~-{igiY8mVks;wkKr(7AuBm$ z+XITw;?xgK#*cvt3A61@=hCyYw)U0nG5W=K2QBvBHI#iF)U_CtO zShtm_w^EMC%-FB#H<#~|DQjzd{OJ1Eg@)+3xzWk$gbq)RF^sSE$N|98u%CLy^Nc}x z6Bo8!dfC4UEj6z(>5UQ=UN2;Cw7Ivjp<@952E6^yu=gux*Bc*$D{SDs`_?v4XkaSU z;3{w31>k$zICLTuIXyh~6^j{=lco!LO$BO4K+V&nr|Xpi#91X8Af(u%(9Hz?7&qa_ zoufcC9<{UW9)sKB*lVrU9OCe4kCFPVg${u5=$|)6!O?A`=ImL-pXKuRzaA+Q(3QWP zuJn7eoqd~w^w_3isFCAlp#~+@lf$FelBHFZzq6YV_0|_Zx`4}|3M?ORVxFHdae=UG zQcb6XrFh9up~9u@KAIVMPm-ZkhEu?FESvqxdCc=f z54EOdhTs%}{w|&9<5+fX9O|xqoai~1LK7Njj-f_#8^y5?e@ONMd?g>1P_6OUFf{_0 z{ybq&ZAKpeGWT{9G63!!>>-52o_Z_E#+TysD*;rp5hBQ=VZD%uV3lYGuCy`X8oqf_ zpwipe;1&$lPuZA&T>;@g2m>WQT)KG4yOVHu2ce^jrnpN}fVVpmqzde|=t8uHR&DRF zb=Bz~!?U%n!Kh}7!T@Ly1fm=QKFuJjY2Z4!V((b=GZS;{475aJw{`7nUC-M9QP*M{ zrj}yd3J(NaCr~K1w1k-I!oVs0IVVgT=%Vg!lujJCxn*k2s7-FJt^4Wx{^@3`3}E=b zzNN*k-fuzR()y3|0)IF?y_??$twi}P#j6d3SA+tTQosYGN}fnQUY1I9RvJWn(VQ^c zxK5J-yg@JSed*Zou$%33ok%!F!@pN76g(J zvxQGdxh?_~Bq~aiLAD>C&H_2-f6~(_f^$V^_o%)$l+*9eE7gHDd2w3Kip7f%!#C#& zRzITjHWP4$>LieUTI&XuS-k5T;pZa;=~x;EUcVaR`hOJ@!ogybDcZ!uPX~xYr9TFh zfjK+RRe_RSyNU(vK|ywpi=AGOmm8;96#$ccuBdLX;5Sw6KT;3|np~TmILrGieY1@? zAp+#&GQJnm!fyPtqdC1n1UHR}S;0zR0(u@!jpFgD`kBj0VV^?u+6!I0jfsU0Vtx6QJ6vB$Q^r)fun}qM~RPqSQ z8_U?!l#>e2&#v6el{x#fSRT}okcjb~33of&o5L%}nA1-T*Fgfpc>KhK4rqsg@*6^t z4GP0rr2|6OU48;;23lV)#4cFWYiZf;fPw9_}e>C@-%Jrz9T3SiPR{a}m_VJ7kE!Zd}*+O}SzF$vz;hVymfs zz?{>fl$@PvMLM)?@dau6JrxuTBT04g614#+GxHFN6Iv2lJ8d$7V(`R3py!~wK81+vUK-GOLL|65&Wr~!_CE`@47&scoZ0seM)MWN{K2$Wh6A+j%@ zzflL+4UPLwOZaJ(=cZ-ZLT`M{(RdP2`^bojJhL#eYShK}95pvpJ;4hk>1Ex*m+d*> ze}_=B1t3vSNKQ%;JOlHCJAJ1UbGh5p<$TKFoW+0*KfyfbX!)y+K z?h?Or8h+1OIbSEdJ%OlKCAfQU4(w4?Psakq9Ca@a5G39KO4OR87f`5ZFnQOC+faA+ z-{Hw`_@xu_D$I5a*z=d+!OC91;-ozVxpGGEbAq=|0pl&906q)X?s8jYUr*EM5bWH zILu>c1ZEr2P{-++m4pBw;N+~=uV;sktlGoZW@jsm4n6R|YXevR|3ZKo`RfbToLk$Q zs<>!_YY@2BJq=z+*oo z9|LvQcbg7YAs<*}jT%@x-;}@_C2;7gzS*nyjM>}?w*3C1C{tu0(7&m!&F#FDjkubQ zlj#OLMWE`Odf(r!z5)tKa7dzT5M^o#RX%Qd41ZF50lzZ6VwJ>izEUfA$=L(Ea*Ggf zO=qV&i7~_U#pyuMY?9fc1T@GXS^|1mu$doI8M?Ubzhq)}KMAkW^BJw~(7zAfYx}GBrK3fJFnHL0BKrU*GUSqHPf0Btu*7f9TgJd~wSOj_Je?{7mX==N z_u10WZ|=8FW+spEE7A&-jjSg_VPg7iLho^<-$@L>e~oW=<>luua`x8sVg6VAERCDL z?r~R~)N!P3yJmjzyC}e}C`!NW2%UTJd3nu#B{Mnd_et8Z0XajkzN87a%TBVN zS_Xg%f{n#hV{x@m?Zj{M8{Gx_+U@%1tH5Xa8Z_Hg>Dsn=Czlg7qDK;O!+opn*UcY( zM6#QG2Jk8eE}SEqdKuRp1KVUz(-NgviCr&xjt^A2FFbdd^Ha12nQ>6C6Zp_nZCi ze}y2TC=@7f0F=!eAOnV^5rQ!+X}=k^OD0*U;q*WF4bW|E_{;-EcgGW}BvRRk8RJ$n z^Q;g`;ym1@fMCH)>eL#>wA(DJMKRpHOXj_IKPNM%nH4Vttbesp3GLZ=oioJ5{IWZN zkNXc=1_dH{NSG9$2{64AIS0I8&8DRRWiTka0yh+zDZEY+5^V1t`o8xPJlDqaJ@F@q z%nToYq9KR%jc(5}hYVR(00B3+)_Xe&Uf;D)`(X&ZAV5eMr3rOzS^unU-YxFb0vCg3 z;PN}j$&L0+6`XrC{HZL~P;=PByj6*D^o}t-T%n{rv9q-U6jZxO-9}8{$byYJo`_2V zFm>Msw)kWf4x&>0r_yc#pkQrdgGE$1o`QT{po?=tP5F&uTes!}Y@3uU-(|S56Ab{7 zg0=PzK439@i5g9w+Ox6&!Rw!~VLHFRQJT(2CpI`d7ozOjcwzY4f(D&madGVIzkGYX zR}s$3{fv0w881(T#fX?GZXKlMRY5Sm?3 z>$&IrwrX6{=g>!0QvTIDUt{$M22dl0GvdB|1^*vLLT#`G_+OUqe5I(6fFr~p(l|ox z<8yH@>DJV{6maIjA6|*u%`|%!TnmTnnnenlp z%}iStahAA(j85*}MNw~fzW9-EUZZsIEOql&qtBn#cf2*3j7q;dIbFE{yx9sWcW_ck z9XHUM=NaCc5ugD&d;z@fP5%!1-l&NBUkUagfp&S9fmT?H-0k8yAC$Nb&RJ(<S?bYHm11P)P|?0nk! z7f(y(brje9jvo_A%hJ&6)fzPzSr6=EgEHSk+|?fId(r0nG8O z?;n%dyBLO+iSwL2rcUmO+a*jA>`^t&Xw4)XKeq*Eo% zpr=1d0sD{36X2N~g-m{DCS(Z zYhmVch1-A7E!fXpJc_iD%Hu<@qZLhrXwsJy(W|MZLRSSLaucCS!Penu-fDa@`mkmSC-Mna=fG8HxrY zs8eWn9j(=CQ#xS{;lhP^YqT!%pHQDEs=VT{eVbiy%gTukpw0n!__Tla+LkV`p%g11 ziv^-kKcd+-f8jgyjW6=@!q<+8fSj;CzfJ`gRyRPn#|y(T2Mei;)nK)NX=#i1kWys8|tg(Wqo4)!nE9@qonBLaYDf-K!dVx zt3~={{K@`NKg#0zmA>=nY-dX^*g6|PeW^9Z~q=(8;oL0Bi3ZD3|jmvc22`PD15!$gkU zG*@Z^nc3HXMltiq->1${s$A2zY)iX|W_0|a-ybVpU`R(e#E-YKf^ij$5C{PsN&ThH z?rsW7p33Z^Uk(%x?!^(2y8wcj4cv#hG8V8eeq?mWXbvP(ONjNloJyPV!zOZjP!kFK zmkLv&3)o$id^94Sme1XjW}66kv%0f6%(0yDa*ss4Pc`-D3QVqrB3LXJN^}k=^v=ZA zYo+oHN;*r48jY$(ncTaFq~yCnv6sMt*LBbuY36x;hqKP6dVY$q6Q0?D@X5VIQa9B0 zKPSmget$Qs^v)<-gzuOd6zJ~{d!>4Mf*V&sI}WaFjPIG&FGmwS>Tep^oK8$Y_866G zHl%ZJdg)N!ajg^x=4a}OYTn#Pd%b%zk@_AM+AY@hHn$R6BB;$Q3`z+bUS)dw@&Z}H zWMF(`gj4;oq1P`vH-2Ls$E06rnzgfEKrNy3Dhl3bMG8{{eD|&IP<9tgw4Do$=W?jV z=%gvH@E!KOCSAs;D%#OpAG=o}44GSD0E{f|QzWE0D?=^>D89R@Ws0oBrANry(v>so z^W5gr(hHe$D$)Aa={A0z-3K~{0rDKWR0AC z@j@xEjACVh(a&OOSEvyv=JmI?b>RUB>S{-n650$52P6mR319C;qYoPldVl^4_{_uc z^PoeRs%0x^MR)_=YVwruWgFcRGFgG%{Iq^(H{=G1MKdR3G#`oaO@* z4r%K)GXoKgn1&dCE?t`rePUmoHCJtdw-saI`$113AB##51Bat+Q!58k=|iY!K1^%q znG2Q9T4nwS+`U6ve*AyPl>05J&oy zmfeJ(=dX!!4gPl3{ulesu(@P(En|JZLYjU#;E;6zSUVwMgddUdBgrGEt(|S;v!>%S zeQ|4h%a#l!WR3S|ypuW0*#jE{)Xom0a#9L;GuS7p<9hXSz)kYwuRa9{@=#apVZe3H z)H>uE&`um%ntn5G=jf!3C4DXVTRi1N%5)RyVW3AW-hJN?lw8ywPA9vWpE1?c-&$>% z***K~GuN}D)!XoLL>czy(hK5x{`YsYJ5AP~Y7eCtDc|4GF;L1FQ%fT8zf_PWAr9CL z;ATN}#}OM~Lkh>;6kaPSSI&Mhsu;EVx!xxMM_=%CSI+->f`ne1>402K?KZPt=JKea zyj7D5SGw=}oY-Fc9-qrN+c=@R!kte-?MT(U_<1&hgpZ+Pyx&`5={MdV}8YN5@|1u4ykuud2wde)}dPov482+qCe;eY;Bn z9=6F$bbhhI7%hl&*o#7Nx5bjv2cs#rK@# zjWRswrro>Uypj1QnwuS@8|v?-FY1qyyxCKKi~Y_S|&Po)|v0cZy(bOWG+i4 z$~&4d6)!f4n~6Csba{QTv9DVc9wy4@vH0geJG02ZGR1lFZ}aT!)sL`&0H8SgGkbrT zWtBsJJ~>=Tt8P2vS~v~nG@N#FeOz;g74EaSeuONPQze5R-9&HS87HJE(Xr*9+`CQJ zJW}sGz7iBMTKh7M^YGFX@8PAF^qt<&ORy{fChjpqn)3QHmS^z4^-fZ2p^;UyO}DZ# z634p7!xHwN40Pu2^WI6p4e=HY*R->p@0?E*8-e}%)NNLe zolP7zs2p1?UJ{=%%HX%HbskeL9s$Meckl9f=!SwUbjsV3!Q+MFWF$3$8B_OXw3bfL zZ6M+zou3A4R@;gcX6&UdOyi78jFBbkD)Z>2wmfip(V`USJHa&=yoqc+TeR6(Sew0z> zt8*AZ&f5bj&P?^4GyCNljQX3NQQZq%x{(NjD@U!|knENFNd!Er-O|Icm*WJh7Y1zj zCb9lX6X3tDmUdvOz`gbsgHVrr+KDRDt)iycBaQB0CEC1tK_r8?Aa`@YI<-r<@Osmo z{7kkx#KHbV56mro|L*_%bD{4y_6`=efr8}t$@t<{xo(Yi`$%rd`&|FX%+UVT2c00t z(G!%kzYpj!B6qUkY*R3Ni?d)P*_%-I)M!ld+J<-~;tY`AIl)26TFk6DC-<@}*qPii zOX@|kiznVv#k`PlrXzk3u2IZQDNPoh>>R%0X`0bu;`%^@u5au&Yb=w#=G_Nx?;NbZ z!Sy82o`cTDPLPZ1nWGq}sC)_T3GF&1z4U3w%1HovclukI2w^PI#peNFyv5BO1ns=1 z-MpxLa>O&sWLl^U3LX6AC!-lz`lx6gQ65Q)-l@808Pn7~u8U3>92yp5_xX)JldypR{$Xf-1PLuyG z7!r3_X0HFYW_oJ*bZZc`ib|`N zu{=_~L@aXMg|n{a(EMz7iSTT8x-`Oc#IANHL$f$IJb14?A3T5X+6~iCbQBY?fK9EN z&l{Zc+xFH-a;Qwy0eOSrRqwLT$Ik$YSm>6Yie05ltQxKn-5~9Zg(5clDaAddoZtBM z8dy&o61zS8{o}K6T1<~IO4p5VP5dwKp|G;P>saB4!Ez*zu1ybju(68sC#9aN4Yc4v zBJaP=q7vQhuym$Khk;f(oJnyvSsUW7I>FAn!QP>Z;ofKTu?DIRZMGwYg*x2ioB-f? zcZVa^3sZM#-1@=s)d1v+LJi^vMjC#G8Bah@;CxlIpNQ|@bzN1^L^(DEXrd=Pd-rMG z=R?^`+0=09+u__hfvOR{^)aMkZT3)svH(_S8a-@-g#Z&<#!}z&&8j+BWiodbJ?(Y!I~IO;FG3oSaleFbIAt zEqEx-42}6|0wT_bUk*weti<2V`D>!F$DL;afBG2x9&`LYT;8wuTvw0f2nyev#!Z~w z_voy{(fcgHeC*{h*!`P9JTKX-ktD}scSz^W^Qgf*f;T{0HN}s~^M_o;WD>fiQ)?eVAX-02q%2Wz z6E9n}krF$Ma!lsrDIF=moFRrA2iXllxHXF#=bm5lj{H?FHo107loWe+c0Hjgu!WUq z|B}A1Pt3s9ZJ5}cXY>OPI_0fu?Qe71?@ivm%^e*(rx`=~<2eAbB;IA<(&7uomOA}2 zQYFk@N5nJ68qo`!oWF;yc&%{2QbQ8{k@s`-t55vW*%ahdg>fPM0^t>aND?r}sSyw1 zphMq7Y1~meC;lx~Pm8+m9zGwJno+y)KAv3Hy!$f72 zc02LN=Y3v%nX0AdXCkDmmPC=yrf?TSV#t&8`I`o41-q-d6;KNMkm*#h(< zkbg?&Pp}3#RgFA*5_IqW-O5siVsDSSzVDjCxbr4XNN_z55B~4tQ?>Ix8)Vl|wrhFA2YVReK%$|Az#ENks`qF|*9agLgn*d<`3L4^GL=5)UVc zvLjqEWm>i#qn1_r@T{@V;02<8kAHy2?49@TY<#!1u@M9ZS~4-)UK0YVC*x7m&}{%e z`%1{*M$RmMTWfzf`SvlP>;Eh7t>3EZw)oL)qe!TTFCe9qw9<_rAkxy^NO!jq3J54j zmvnbG2uOE#=LR<2uN0bL3}?Ip-@`U$KxU^jTCvDWH%E z){r9uNwc!kMU*0E(DUP&VPoc&JIeB4H%*}obsBhspD__4ZE;9^G`q4E9>!V7pG}r* z|E4B^AxzM;=Wo>780hPZUe%Q~Et5NSq8Hh!xR-(vdLnjJDPXY3r=k6pqo48=xxo9d zFHt!ViZwUNO(bPANm|~TY!5^gMyr+sb@R!8fvx${HTGh z|NTo>ijm6&kIrR1U($1tcnMJ&HiQ}kujPf_>{0Yzx2>-{vUzyo8qYOLi=-b3e0&Wh zHgFjsdV)Pr3Ne@MtFvp|6I*fbLw-essgxE`v2eU?(E=~Qpysdz&_es?EGsB1Ojx47 z)TElwh%M;67?yyBkA_;5QfK{nIAW8Qp*>%zu*>vK7D{#TTkBji_#kTJ;W1J3z|divza)1RT0OO~sC14*%2dr56*d3Zoi@@qkYO@gNG z)?%Fe)`D?u7~)*m35tq0+Lx_CezuqgzflrmaI_bY*xY_BB!g)epOI$*4!{|K=gV;c zC=$jgknd5^xg%dSR zKd|?A+WtZD0PanQAQy@j$UeXWf8Fk>o0mIOP|;Uq)f5;irzzNs@Bn&%sY1Nsi8SWw zQ=T2$t_4P8KFNO}wo6X5MI7p<2AWfY2T*afinXm<_Rm)IbCl(4sOVL;-a+3#fQGuG zNs&CCCIJt5*6QOMIgHX$8NPs4u=?ZZa0>Mc9KLSzh@DHvry=uIovI4M3a$P_Hz)m+S;4&>){LUbo4Proq7OIiG*kaCL(ZK^q;w2p*tj z8}?ssCKU~A_$n*2u0#p8rf|~jBv{>_{Z@!Rt-c6cgM4Kn7f3Eoh#)3-)?i$W++&j(CzM2xaZ=ZSrB7iL z$zM!|1cH(B1f0w4i4JHpAp2wz)Aj-B<0m>aoe%u?nDnEtgrLE$V56Eb=+v`&Z4u z8!+@=?S@pZx_HN#EM=n_ ztL&af5JAw;ASFJ#|NUY0QMuFEIZmfHV-&MY|3r=&2dm_k8hTsbj*^+6$yR)L+qb^z z?TVk#sLu?6W|$L;uOphA%&(qM^+|I4DoNT5XD|s*!ONS69i#%1UXp!2n2)u;b@6e z{+87~Kbj9Fgk*$$Q^zXWn`fvJ*8CMuPIzv!2hw_6m6@QN5Qu%XB-@Pd!8|=z zCuW_`qzjd$X1mp8eqr&(?If^~9qYBHq6NRXbPQGnG+`~Nzw~7vmJLsu0C@qEvCw&jZ>J6q&l87uEAuEm^%e5i@4>I1^>1w(e;+mhyDID6Y zIeZk@?AA;zhF6|;FmP?$r)9zA($!Vg@Dl1?EA$zfa#WZpF1E+JBi<$d zMBjgt)e(Tp;4gE{c~4OTF)Fs+ikXe)lwlrJl<^xJ(Y!Aj9~-~%Pzq=7;POFgUmrRK z9#ym|meVAS*IP}M$XcL4-#PZxX+o4)^#TgiKrHkZZ7W@LRakXOKN@M=h-93Kq-Y4X zKnR)s#)#sljeKu|vtwfk+rB&@mD-|=!-k(3Z3TPFPRrz{!d@CE3k(~AoZ4av(Z7cT zbRN#89q+;qI9seHc(;qlxWR(8?ddh9LgXo#2FGHA`(9g_W8{=DM*G zC^i_f#B@0b4Xc1rqMjD)UAqO8l-qK2=96~YwA#PqfdC;IxTkpM!@#}Hyc8x^sqn59 zqbD5HD%H#;_D;hGiK_1UX_SLdx(OEP4`$}tRB>hO=Hzpnr^nn;HAwFf>fN{QEo{>v z{jwez$z5Ce6CBsbU5mz~Q798zMnOZu{=BEkcLaHLnE>MNYY&3t$L*yAe%M?vS-az* zT{OATQMtzc_aHRVl(zRqmi$)L$kPg1AKwoYp`XD2D${sn28_c7aVbE7vd}w;>OAT4 ztPj;WmXforYX2k@jBFZ>E03C%j{9I=8<^8RC{+M!AOqsJL#{64N1xv?KC@tKvitDB z?+2rlX1Qf0x!2n_(KE9ZJa2^Y@%GqfXGz5d442!eTIP4Y2Rr8Hhw3T&e-E*(%A_1s z%zK?z@9aL?XBxMOHzqSVHZ(DB?^4qgU-wo>hAi(16i8dT19R6fYRjqqwlMFv<6gW@ z@%XIEq>L=xRprKqfwmXaD)Es!O0*&ER9}51=>H6?mBja3wdhBP8*nhowGpcsW%vcK zsa49v#HIh4jLQ746S1tWN^!rCnJzOahlG+TY#Xv84X3~ecvh0*1^zu$BpA)JDciOo z-je&<>W%%;F!EO{=i|_SI0q<+f%+$WWFCxzpCu|D@7@Mz{-M(@b$RCV z*?iYfg3Y|t#LxD4_fFF9dN8dwa6nVL9hfr4z*((~%QoyAX zRY^g`8Zh(+U@~)RZw5j^;#owv^?z{$RD=#IC_ptL^s+M|k3Ozoq9bB8>igZ&MQFlG z-yo-mSVzbzZvsUdT0@lUZ2Zxi-Ez%P*5vENTj;&H94KZ&|24T~WXuD!m9EsRmf^Qo z3^Wqi!x<D36MZeWJede6D|Yp7blrM+-Gm zOa?#6jncsK;DbXYz}^Ak+T+?*J}W8jwJ-Ws^Ma%pzH89>6>k^kQGIX`8SSZB33}Pu zDYnO!tI1Su0U=eem1khZPQJhR6;Z9?K3-GlmU8SGG}=Cq9lDRSJJoL|{kiIHZvB2q zai8JLQ8-h9Q-3+9bIaU(YwY869~&b#hmqlg$LYkIVk2h;DA0g?4uG_vZlC$-#Sxzc zg2VMn%F0>I18(d(Z)l@pC|B>MGo;Nm`LmV67m@3>?suyc>C#UpCXftrc#GjcMusrz@G5(7{kHBao?d= z&DrJCR$^6cu6cRuxwL?*xV)1#01ku&QK13Jn6y?mY561wamnKBCh=j7jSJSU#+|Z* z6EtxtR@B=@A}hkp={`aZL0DxN$FBr9Chtq&s~eeGmo##36l~#f^V;c;{GQ&+q?2+R zRN64h4=1Bklaq-Jjp{f?ohsYtf3x%W?pI+V_k&(2b7Hw_B_w10W{~&L&jeAk#TiXY zqBK2wuw9pEj+kbQn-AKQuQ!_JEloUoZ@ujhkl*$&__9BqO@nX5zD(9<{oyfAUNxPK zk4$n*EIZHkv#MyMTW?Hl*)gBSPKy3PX@TxI=T=ed0f1qiwFvdLS3@oktp4riWnH4+ z)sXD%$jAb<%b#NAwML5#b30&P%6S~|)yDhL-2_(zjdA*|ITx@9#FLtfwFD*)oy@1x zi4EO**H5)hhey%r>&h+TuotB6!HJ9}tv2qH?e->_>{W6++La|9X^$ot^dF`DfE?rU z;AWg)Xcahim8(TvT4P%E1M~RoBb5E)s88-ZsykF)~XsAoQeQOH=-~Lz1 zc;vt*3*}hsbaQSP@9}CA24=9yUKv@+Z0?m(?CXQDWV)GKd6Ml3rukL5x=01&+0g2W z2)!}hcGTIF(N>t^msc0NrNs4{rC-g=SC^Ke@`QmXgCF{Bm&>ymD1%j|DDVxtc->bb zqIx=h+C^1f{Ek+TcrDRRN6Kc$NlCfXrUMPaLYiD`;5b5)!|D9sH9x;&v|0a&~ykoru`0DVxa>y{O}(AJf)<-f&i*K#gJlLshUMIC<41 z@Ga-NT&Wjqr|`yk^H6PG(TfFdY@U%qEQ)!ACVNftaa-izcHFFLiFuzn$7SVexZB3- zX&?*WhkN%y4hI`M^Vxt&Ou&Pp;y2+Lad$j}opAIw)Ro^0p!NQuIj?zkACx3N6CjKH z6OjC*_hiZ@#<@Ka`;XfO1ct##lnVXMW6#SIu6n&&&T3t*63DubUI++1Vy*6^%!Hl1 z%h2n4U*y1$)wJlnaCTh4ZL3D#D{x*Ft}mKDm=UL=0H!{Yj65#?ggiSNtvhKKW1vpB z9Ow3b^F|xFvk2=-9{Q0Xh&0XA;F@r@`*1acez|cLbTs~V@q41mx>o|Od9Vmt;?Je; zJCT0$L=NEd6YE#_0`=vN|^gM262*;9XpKMc-c+*cC;Di~(hvobvVBY*HO?B zAA{t8Nxzh%HAm82=~knj;}nJWw}&hsvEbQzfT{dPoHJA=-TsFY?hY}n$Y&PCnVFjX z08M0ji%AFZRX)OqnRn8}^k|gy=1wjUHjlw7x4jSimbIyNR06m_e})u`*slSsMR?jTc9We-VU8lL5;u~Fk&A*2pTvWl9<&t?}3EOppv zK14dwHsZuTc{IvB#It?zLyG+aqYq!*(PB%#U)^^IFNL`W-|TE1kPUS|9kj@dyV>9h z-y$}i=(kS(Hl-uJ@ttRLq()PyvJ`i*StoeYI`XXNXv!RJSZ;SPwaR94n9!Md@^L1b zF|IM95^@nquz_#piIqY@nL_rLn5X}IeN6gObFRfPW&(B`tJ6?o;fC$qQ1Z9is_+L} z_EC2UqwlAi5{zOxdY?b1{N?=~(@zaI?1yPkj><24D*<2S?1b`i$acfreNa9@lYUr{ zu;hntPXwmc<)S(Fv5u?$=J+c!(D@DGrW)P*8_r_KdAIc`sdxKQhSiqyyxwI=;EWG8 zr?QRy$DZ;4`p$>J^7Cd;gPuqTn3#Pps)F;+Rc6&k&In)MNE!uN8isbJm@y?nsGT7QqfJ+mkFHNHYhT<2 zCokvFswu^mQNsi)B#Ix}ikn>noBCk9I(qf#L&YFHpx_sB{x}tc7|!zBhGAs16X!h-LcWHrF+3=+oQOQ16|FeX~~ZVdHe@Pp~drvB%&+ zdS|JNMNH?G0xIAC1M$^?5>f;A&yv?YSiR26F@8|n2sEJdyV2t94d(nq;tUFcve zS3LlaM#wTa9Ai3C;s>hzN>G;9_=QcJdyOGBT!0{B4k0e=C7B&nW5&29hF(0HLyd`r zE^`gTY0Ut^M1#rG1k6mS$to)pB(W>zFA_kACybW%f)I@awzQhU&?y>0b_4)0KwY2r zP@ISsoD{pZ&DVOPh^!w4Q8avM0mCu2W{(Y|y9ctCAiL(VpQkD)C~i!N8tw@pc)kHp zgP~2MX%uQeNm=TmLa$ydFbyR=K@t{BSgS5>bA480aBRxCZEF`5DBcgyk-J{F9aXS0 zD^gOkb2JtR_9>@7TtCTPQR<2gcl9K7~5IL{4Sq2QJT}QqA zKY&OuK54B!FH>ME+2&i2!9hS=u(9{2W|LVojw7DJ{f8e#QMP zD$_X)S-Sn7jvhlDQK`n4JGs<~$bf`|Vm|G6a8}R1b2bFD^ zV00ba8&L7E$e>O(18Iu4(E8UWqxD-Yog~D&)YcWMqY^eCjK7KAfb+j;1%Ypg^M!z= zd8M(T+5V;nOCx~xTDx#$sC+Za0tiR?L$jtPU74TS|E?Ygu9en6pyRM@o;NwUVp~M z&!u%fLRgsSaWHV19d;xwH9M9*K~dfE!2?>Y#LiyPdQ&rHBW+1XrEjPd3<%tBK~F-- z*F&;CmdZk7hFK~=NI_R_GNm(eZ-MIxZp^<75lITvS)(XQ6UEte6N&_(lj|QhqCGKy8u-y#RJS1D}B?TM-v8@C@WvfoZsglVNipoZ{bkFZo?c z^80lH9T>2`J?#7qYsAm<@=O#^Np)ap0-q}J!`8S*YC(lW>Sck548SWWnnkj&6`JQM zNa36?l(!b=Z$atNC)1ZR^F7nu#M1=|sG4V7YmHi6R3hIUydFl^3n%y|i*`}xVt`x@P4X=f8nZ+%e92kMuzc!;dIy^dyu&fn zSAz_6Z~$57+9$>B0N(M?UoYyz4!uTzU}z*bN)68kj7w*Zs5#%%B<1|hVOLCFgrlb8 ziQYgw(D1yYS2Q^2Ar=M;D47A;y7O^N8Ok}bY=k->6B~HO>lOzxFV~g{c0(LUFg7@R z?`!uqd+Uu7DBlKF=P^xzCmudQ`4DT#c8*CN6^&B4fzN~$>LHp67{wi~|67R=%i%ew z@)$8XHAd-oEy*29Hp@Ct6vWk6S({e4uT<_pCHm3W`b+mppU>VfirpJx=cLT%YCspD z1l+;WaEPlPUPA7}0m?x@h1S11fMgBu`|5Jv&qXk2=4LsK5KWMm9M_Y(Hd!$A8+ULm zHAZr%&CR)hsjCH9$-OV8xeCeGZUhV$i1%C#8C}r%#TK!IDBx<8$%UZ@fx?gmO>y^> zIgUP*(SrR3ukw#=25nTWX!2gzIv_m%RRj&TY69Qxm{~g7ep27xt~J9<7ekX#A9bDn z8!!x2rGTI0+s{6CHro0_xs0PHFzu=R7YKubB&huVi{8N+m~oVplmJB#YZD)*Ee{-n zA&S@wceKc-Zd#*S@gz=a`15DMvMSzVT|H*{wbLlzTij*2uxCQT-q$!V@Od!q;Ns$X zX_P0iJGe;bDrHofI{lkWLsbI-z4hz?EiF5MRNMV_Psfdly3#FCi-HZKDkzwH=lxaK z+oq8O8a3>O*HbDqYH)wJfmA2R*(bW56r%o4!U)v&Tb(~T)AF-LCda%1;2<&fYo5M_ zc>#XdM;tBV^T&Dg^?~@QQr);SVciCg*(PYUwy+ z*cX8VQt#wXFV!?_x9E1{X*afKIx%fjjs-B1{1_4lR zx?brmt+#9J)}~s9@2=rp7zqjRLv7l&j!KBwi$IwVsQ&s8V4XMVScoc)0H$LuvEff_ z!6f0K#CiGF1Q#dkYYw9+hn>CBW$i=*yK7bD=?SzuF~M`9X_?8A^up?Xk-T-QucUr|@+=JZwp0IquPBL7uK{EUDu3_f3d zrq08pj9Ksj$OZj8=SabF-$(=ZS8}ZWN3S)MD}DCaPeQX=YunwZN&}%(xtpor(E!fB z&VZ-Smz(Isf0pgp%I!_3E6dA046Tc5(|&@u3i@>sJfMvoKJhM1bJy&S55FJK&GiuD zbt;2$4we6ZDx3g1c<&BwPlJI&%~eki$b3|&nIjl^S+l_CD@X#Mb6mi$!4-%)-r8YR zQygW@)^Ny@Hz+`{c_XWAbFEKSKyg8OaXG6Q+5fd%&ejuXJ&pURTh z%SjxJlrogJNKmb6B_=nhR6NH_*U0CZOa{l&<}YfZ;CI+>UJ!;!H~MW~t!la-e-??i zVplgt<>x>#3pQ<`GXJ$^sdE!=KzpK-efQYM)(P@)Sk6G%Pa-mY!}B}^LXqs+|Q zD}?w5oL`$VuN_m6?|J+A=@PJ;!*&v&UXC@UiR(KWR<2TEj;T|8|8K?D1gK^jPJcH% zuufilqUx{>JT;W#QozV3`?B~Fg%ZNh9^xp~9iL53dLU11B@z&`37cM8vmU1=G(E{hYY8nyZ?rt8kn z`A!pZxi*jphvRl?I_C*Tr!bBDK;Z8#52I`^%)swvXM{04#sh2t0sB7=mx$fdh7G~Z z7#{Oa_$ra*SiE~nNS^(@bo1$y4_VFLO7a@0pa|ALIfm9A;f`$H_fNW5a?y-31?Ukx zJ0Zybp~q%>eQyg5CT3vW*-=o$%oOW0%4?Tl=Uz_3UaHyfp=iJ&08_;Y@dJH3!dk#> z&1QPG+%LF?6!V9ja$Mj&zL)o^aY&i*PeZZ>3Z#qkC9mC|G2J ztJF{_$o%Tbdm?mayx8?=IZ$u|g~?^Rdc6%pPOF96U#3!vxj9f&f=tX_RDYor={r!; zypGkQ2MdI=B2#+x_?z$OiA8mvQ@v2Q;v*es2==SfjH9D!S zO7}X}x~&SS_uz7L_bJ^I-OEMYnQDV#BO?{-4+A=Z3WN41OQt;!`JehHjS-sHN`hnv zu%#!ylUr6*+#(Mz-0$4R`Z`GG-3Ra45##lQ=@(bqswgKbE4h3T@s74(3K^1oO>^;O zndz>V2AXy;m!wDAy6t_^?{_avd+M`FwD!Z}{H04c<2cBMw3v?{D!ce%cCW;uW=OI| zPVW!2*f|Bm0)VNwFB9vo-jcr4dp8C?wU@7(Hunqg)4+Mg6SIF!kvlDheVK&XWngv` z&88|LMJFeI8X%>-{tNvJsHIyknMgHiFXpIMNFKLb&l=Z9`7SUtZghBbZ>9bCg#0b9 zrqaW1*{=%Oxm5-h%N;sRoAjjDZz)BBR@e6vD#01YZmS|#&U{qTGQWtC7e7HcHiP?- zfgK8x0j@uls0>ai)9tWN@gA2ZvscREwo4-Pn!0cB$`Gc8<{Y4+)#9qZ-d%Ld@S59i z0G2(*k2ywe@2=uUPpHv5tPGHYeKm(?oeJ64$w?TAH1M5JuQ%iu-b0~LP&pugnoz(0 zm;FmY%d(p1@|IxJOI?5w%`rW}h4<@Dv&N_c<>o3b^K1U+>-5f4P`=Cio9Y7+6r!$G zfZ*%IH;HPR=hD7faEk;2Ypwv&cEQDWA+K?oRaDAB;rgT`D=xiA@F zBMyC4EZZIcd)_V1-QAkJmIeD|s(H%lGCIYR2_Eygh^uqKuhl`wR;+CWE<(?!lZ07b zH>4NREL|L`<&(7Pka@K(fZS-VytQ8R`6_vm30zTN1WzWV`qN-@rF(P zD;niAkW6C&{_F9HHwy#TG}mbpG4c$`_ysu3boEM-^f__IOO(5oRxUSyT+c}pU#0P- z3b0#|i7|XOy)Ki+$g3g2r^`aX{LI~AbKQfh+HbnUeP-zxy5eHNy{E%)3&i&NtR~X> z+jxpOcPth?>47GF`FT5EeaO#v>rM;;2;{4~Jmh*RpcvPV5h*m%$G9!ASA9t*k-C)5E8sp^j>7z0L788hIm{3XazspCE?Z1TRNA(W zZl9$9McSnRhcbExLrXx7tZF#I6Gh@L28#jkcaR;2-q7W?Pm?eJw{vhmy$7~jv=+A!mj3}d_8_&?P$_RsDi6& zK2dqGsN^P55&86<-@ZA+h1fcq>_oV3Rvm&A2Ed-Qe2-Ymk;gBktaeGTnZcET*b7(p zh^tq(du=z}whTK}k&e4q9Lh$=SChB6>{&A6FdH7W7n#1n|05uX+tTtd_PNT1s8J~=9r~JQ!-&<%+Ozsy#5i6iA98mT)%B)* zYec=1rh)F*GhVlA7ssDqbbWGqv zG`9##mDNxESgOrtiC0@PT&g_dnfT@w;)*$YDT}NA;#Mis%?d5Bg;wBO=(OA53TLhYD3tk$ z)&SV}IVd|EcU%@y>3*g8ht2Ka18H-7Z7%ilAD?-AX5>@iXX+}k<@&ALjfp?kGSCpN zokvyo0}lPgvH_^6#VRb^YH`)Y<8~?7dpUb+JSx+;^#-F2e(p;3?AcU__Gn=!q|W5w zqbqu;sdS=pcPZrl-iR88?$BS?S4Y#vA8-9d-Ew{BaQ=YtY(~&3jflzC_Zm8_QafQcpwa6!VT~pVA6C<&0 zK3C_>hP1pmHREytxjQ$)hurG*5o`qZ(a}Tea~o=Ma<(kYYKkJ~4LzlVYO2HbMzAd1 zKL@Glx9E*bOxEG+b(Ziynd&17xTPPTdg6oYwlDa0XgSTtDxBJ1trJdi!Pg9D>-I@r zGM#s(78^(qs86Qe$Ouw(Ao`wgTCgw*%vEVc~B zE`wKcRS;zbtJQ?Y)wr;_Bx&sK3u)p0yGIy-uJHkTbZ zmh7#KeLsJWFAlwod~|Coh9zsON?QKko8w_WnCk?tVI!F6%WH%K=9%*7U+0SdKUdnr zNR0l^7ysU$4ttM(efI(kM)vQ;r7&2?zxUR|{=eSb@4IT1xb~~t$rE`C4lOO@pCbeS zn;KXDD1Vna2?ANQCbDy{aeNpWRuUVVT`J zaXS9lr~0T=y`M%r?h=tdVxGN4?H&;w&tbige$RbBcS~UAMY$bcC4zgZrzI0#x3sjR zt6Q9+SB)aB3sm{ZNfwtuLHtSR_pVL-Y*s=Zyx-DPhX=NHC8b40Hk}>zB) z&b~pOn(E|q=j^O%LPdq;jkbY8P+53*U7dMg*HO78J>ydA);$L^yW*l8#ag^H@qbnp zpE27Vb<7RA*xHtulyQT)>_w<_5JpD!lF6m7-T-vAvpdw!#y}M0w-a2m{N%~J2avcP zLX}Z_JC?SCspwxRZy$M?5VXF%tgdh8?!=V{ z_V=@yU6+yd0sH96zdi*}8oz*o>!t2eXe5RSuGDiTrRj|9?7>`{N%OeL5gqLxLAsQ^ zJns0#lb0q@;ztzwB{o(YGo{m5e1wrKX5Y)~yK{U2gk*6a;?f`f%_&1KTQAio*~6vs zsco+o4(Djt_!9V*_evR&HW74P605z@8C7AwV#|6L^~R>Ze$bC{D;CxjerRew`;KR) z!Gd@9{;Pl$_uuBPsHru|jTG;VD%?7=eImf^{CsPEnN9oP9I$~%&?(wJ?F4HbXIbA*IpFHI6GR)kqeh!CUn@`t-*H|L9-g-B3Zb4LfX7g zNxZhPF@@&U|UA>AFbuENR#MY0PJnu zCRxjPpZ)XL^)MWJ!T#hHV3qr%!VlLMhmxYIY(5yDT{ZD)I}nHc`Zd93asV2o8Mi5S zzO+|-mJXJQc<-S0cxOGwNcTCRX#%&)_Lj@Mww|1@LXeYs==Ssk2JwK1te_yv(vNga z&=z=4^7P)F+rDHJ=~1IBc$^uZX5-bb4vrsGt+-II)02)5HMb>csLSsxjn%BTCA!HI z=Bc6Yb6ZhRu=Wp)Ww?~vBk@P_LoUNKW^UX)bFQ6*rv?=ZRs1YZxTSfV z#Y|6IWzTdsm%~0;~Oaq1IB{lH$TV51P%Z881rz0I{sSAU5+^ z^x$te0#hz0%%rd7(O!Fu+*VJtK{P%YCOD11&zfyA@F&p^TOd;!C=QRQ`nzFR1>1bi zRiI0-0^_V@?)ZeyY}b9UU%cXAVPuy>+ch7x-;nJFtbyF$emR0cYnns%By>>np4uLl z%d0+z(axeo;&NhJJ1dm}TXk~{cU^+)g9CBWW`^@1je4umoIFuwW#ze4X)Ff5`r-)p z@x3tPZ@udcfG$Tw2R#f_hUTO1C_SuU3xj4s!`J~3G z7JtO&ndcvKM#V&BaOP;#yCt9VwysFT#ccvbxXPe>KKUB?vfi93fjLuub?MHXuSZ+M zLW+ta(~&n@bU1@;xUf)FxN)2;XZS4dkF&N5w_q8D{nvgdWA$!@Lu$D`BYj?M}d_<4Op`;+@25|i&QjwhxUxs&d7Ph5 z=gz)M7<`)rgHHtR2*$hbf09tEGpx!&9;u6jSDRf~UhvJX4=A8ntz3i!Zl4=S;*}bY z(^jg{dylQvSsb2i-NQT=$LCtgf6DvaRpFzG@#NT9emQ5E1MhNWX;9*4>=&EcO}v6> zpA44OxI79z>+_Jl0}88EL)Y!X3mh|gNY^F)9qv8rqShV^5)mn$f>S`~=_%Rth#3!B@3m2QFrg1mwF8cTxA%;U`t}%HUq|gz%PNx`I45jj@l|q3X{l@P zZAB$7V}tCor>5Sv_C<=RpdfaHju1QolVv$UF#OKXhYuwwf7}bmpKa)PQ8_RRpt1Ki zpk11ta=*gD?vvs?NUd642#Y}v&y&^cIW?SGvU-}+A#=a-5q)U;%;~-|B9MTkps=jw zsXbESf$WyPY?uSQ$BpzIJRCFZAUII!0lLSDKr*nBY?<@jMr}yPfQ?r`4L^qWrXRagAE6k_-EelLSe*F08es{l$VwtIE&)**$ z;Bxn_b65m*Xgl*&(XGNiJbn~^?Q|9&(L^Z>d-62xudu+ky-safx~{KR$?1^V z>2B&|py5gnna?!BOQWzmIIz_;%r`NPjgD8A6-v|ByHAR*RC_>WOWIr$wYb{-TvJvm zM}3^biR;)|1GDmSN!e6D=R;;v(b`7-*}HFF&p*U)uc4Yb6pq{LEv8B0T*kH)873I1Xqbab8!X*RpqD1@&Fe2X0Oj*gb@ z2s)1iPSIqY(Q-j!tvp?RHQv6m?bLvLjQh;RGT&KkOkC(Exz^to>QUkbx@KV^(fw)i z`_z(#7NcX$6+PW-lTWg637;gyM9kLZ4rYVuc2AWS(rt)t8>*3kM6c&SL_z4no&8!Z z>?0wMQ=aQ0a20?P1Hvywb$t9aP#9jn&0Dd@S!$7+oxNzo30&K{DhDAw>q^Jr*B_~) zqwzU8C$fsSG&y6$DMH(EEOvy$-it~+%b#FSt{f`qT384vCGa>s-k^(_IAlwmlZ(~pxd<&! zV=%Zl%WM=FNUY5k*8uSY@>zi+Sy7O|&i!KVM>@hp-q2K!-KHZWP}L)@vcc?Z>1QCm z%3pFa#DoVeOKw)H={T!7iT4h-RascF`de+8WZ&?^F1W&&EPX_|+4Kt%#^$EZ-j+%s z9c$zFh9|tiq08rOdPRR(F}~za)mYPYjkQV3$__T>?V8lekOM{KjXi5IU4E4-`^Ie-B}ju$lbny50V##A!>B|gNA)tp3KFinTPv_H((7k zsEF?VYvw+__1#5Z;^S>z%c;rZFIDOkp{3@_bDMga?MfIJr&oj~e5$+CiYC*FdE-gO zcpN3OY??i?9`5F6OOHT&j<`5n>(LB;KpH(h;mYgLvG8=ZR^g`@t~i}6b@|rWR)&M; z#;assjEpmi3W~s-Pae$naLizv&!qVHd%E{n>uJGBvN`IdT6_5g^`c;#Y7pqdSiF5_T1E=E4zJ5a4Nbaq281~37w%2n=URl<t^u5afKl;F%?)B&8i(^w&^}x$4kQ(#=K}EtxQ2Xg9 zhVx)Kxm9NOemgj-zoRHVOrPh;BIic6kB^X#P>ZnL!==3kJ%Vj*UG4|q_|18((dMD% zwR}RIc*^Exiji{0ViV(+q3uOBdnL>)atREyujslvY5GP-={&wU`UKNZQMn$FRTUJx zKm2uZ@#l?9+L^G1e99-i7D_Tzwv7C&f{F^V`rmyG{8D23El5r9d7$g_xTvtfu937l zpD2OEuJ&%1#81Fh1NHy%rJxB7xQFRuW>-)9;Bf!Q@a5vc*+NLj#f3j{Vn|e2a&BsB zK62sH^FgMf7YXl7OiaqHiw1vm7TfGS#5k0X($a8$Yn1tCtgXNmnX{=TA%Strt#;>I zn}%1qJEzOQfK+-I!gBGb_D0hu`dB8X_Ai+mf0uq8q{|cvpvCxxXO(9(7q3;sd!>oT zF{|1A^~eN#h zgIx9!7q{cQt*qCsS0SaOiSu>w+sj@uKnE+MunR}k9?hDoX)Kq#*_v$LeGU2u2l!Ia z!hV#QYPn-myTw<&z8@y+~H2@52{RjpgRIqmSdvuRaK%RX5;UJ2>I!Mp^}mZJ<##`&@aUca$BL_|8F3Y|EDH< ZN&L!L^P*`%0F{Im6_R|HC!qD^{{ldpUD^Nu literal 0 HcmV?d00001 diff --git a/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_paired_accuracy.csv b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_paired_accuracy.csv new file mode 100644 index 00000000..20edcd3b --- /dev/null +++ b/gazebo/dave_gz_multibeam_sonar/validation/wgpu_range_axis/rangefix_paired_accuracy.csv @@ -0,0 +1,17 @@ +ground_truth_range_m,incidence_angle_deg,pointcloud_oracle_m,cpu_peak_range_m,wgpu_original_peak_range_m,wgpu_fixed_peak_range_m,cpu_error_m,wgpu_original_error_m,wgpu_fixed_error_m,original_to_fixed_abs_error_reduction_m +2.0,0.0,2.000008106232331,1.9816054105758667,2.6086957454681396,2.0317726135253906,-0.0183945894241333,0.6086957454681396,0.031772613525390625,0.576923131942749 +2.0,15.0,2.001055717468904,1.9816054105758667,2.5836119651794434,2.0317726135253906,-0.0183945894241333,0.5836119651794434,0.031772613525390625,0.5518393516540527 +2.0,30.0,2.00226426124637,1.9816054105758667,2.5836119651794434,2.0066888332366943,-0.0183945894241333,0.5836119651794434,0.006688833236694336,0.576923131942749 +2.0,45.0,2.0039196014410727,2.0066888332366943,2.5836119651794434,2.0317726135253906,0.006688833236694336,0.5836119651794434,0.031772613525390625,0.5518393516540527 +4.0,0.0,4.000015735627504,3.9882943630218506,5.217391490936279,4.013377666473389,-0.011705636978149414,1.2173914909362793,0.013377666473388672,1.2040138244628906 +4.0,15.0,4.002110481263491,3.9882943630218506,5.217391490936279,4.088628768920898,-0.011705636978149414,1.2173914909362793,0.08862876892089844,1.1287627220153809 +4.0,30.0,4.004532337190006,3.9882943630218506,5.192307472229004,4.063545227050781,-0.011705636978149414,1.192307472229004,0.06354522705078125,1.1287622451782227 +4.0,45.0,4.007824897767399,4.013377666473389,5.192307472229004,4.063545227050781,0.013377666473388672,1.192307472229004,0.06354522705078125,1.1287622451782227 +6.0,0.0,6.000023841859836,5.994983196258545,7.700668811798096,6.0451507568359375,-0.005016803741455078,1.7006688117980957,0.0451507568359375,1.6555180549621582 +6.0,15.0,6.003164291383763,5.994983196258545,7.75083589553833,6.020066738128662,-0.005016803741455078,1.75083589553833,0.02006673812866211,1.730769157409668 +6.0,30.0,6.006785392763159,5.994983196258545,7.725752353668213,6.020066738128662,-0.005016803741455078,1.725752353668213,0.02006673812866211,1.7056856155395508 +6.0,45.0,6.011794567110083,6.020066738128662,7.725752353668213,6.020066738128662,0.02006673812866211,1.725752353668213,0.02006673812866211,1.7056856155395508 +8.0,0.0,8.000031471255008,8.00167179107666,9.9581937789917,8.051838874816895,0.0016717910766601562,1.9581937789916992,0.05183887481689453,1.9063549041748047 +8.0,15.0,8.004215240481084,8.00167179107666,9.933110237121582,8.051838874816895,0.0016717910766601562,1.933110237121582,0.05183887481689453,1.8812713623046875 +8.0,30.0,8.009082794192024,8.00167179107666,9.9581937789917,8.051838874816895,0.0016717910766601562,1.9581937789916992,0.05183887481689453,1.9063549041748047 +8.0,45.0,8.015708923342416,8.026755332946777,9.807692527770996,8.127090454101562,0.026755332946777344,1.807692527770996,0.1270904541015625,1.6806020736694336 From 14558f15481d4a42f1aa2a1d58ca4eb459b8539b Mon Sep 17 00:00:00 2001 From: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> Date: Fri, 18 Sep 2026 11:33:15 +0900 Subject: [PATCH 11/18] Make WGPU sonar runtime portable on macOS Publish macOS loader paths for both sonar libraries and replace the two byte-copy cv_bridge conversions with a local ROS image copy. This avoids unnecessary OpenCV ABI coupling while preserving BGR8 and RGB8 message output. Signed-off-by: Yeseol Gwon <172019512+yeseorizi@users.noreply.github.com> --- .../multibeam_sonar/CMakeLists.txt | 15 ++--- .../multibeam_sonar/MultibeamSonarSensor.cc | 58 ++++++++++++++----- .../hooks/multibeam_sonar.dsv.in | 1 + .../multibeam_sonar/package.xml | 3 +- .../hooks/multibeam_sonar_system.dsv.in | 3 + 5 files changed, 52 insertions(+), 28 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index 9165e69b..f4000d7f 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -25,7 +25,6 @@ find_package(sensor_msgs REQUIRED) find_package(geometry_msgs REQUIRED) find_package(OpenCV REQUIRED) find_package(marine_acoustic_msgs REQUIRED) -find_package(cv_bridge REQUIRED) find_package(Eigen3 REQUIRED) find_package(wgpu_vendor REQUIRED) find_package(CUDAToolkit QUIET) @@ -76,7 +75,6 @@ target_include_directories(${PROJECT_NAME} PUBLIC ${OpenCV_INCLUDE_DIRS} ${EIGEN3_INCLUDE_DIRS} ${marine_acoustic_msgs_INCLUDE_DIRS} - ${cv_bridge_INCLUDE_DIRS} ) target_link_libraries(${PROJECT_NAME} @@ -100,14 +98,6 @@ if(APPLE) ) endif() -if(TARGET cv_bridge::cv_bridge) - target_link_libraries(${PROJECT_NAME} cv_bridge::cv_bridge) -elseif(TARGET cv_bridge) - target_link_libraries(${PROJECT_NAME} cv_bridge) -elseif(cv_bridge_LIBRARIES) - target_link_libraries(${PROJECT_NAME} ${cv_bridge_LIBRARIES}) -endif() - if(CUDAToolkit_FOUND) find_library(CUBLAS_LIB cublas HINTS @@ -126,16 +116,19 @@ if(CUDAToolkit_FOUND) ) endif() +set(_wgpu_target_linked FALSE) if(TARGET wgpu::wgpu_vendor) target_link_libraries(${PROJECT_NAME} wgpu::wgpu_vendor) + set(_wgpu_target_linked TRUE) elseif(TARGET wgpu_vendor) target_link_libraries(${PROJECT_NAME} wgpu_vendor) + set(_wgpu_target_linked TRUE) endif() # Ensure the Rust static archive is linked into this shared library. The # exported interface target may not always propagate non-installed imported # targets across package boundaries in overlay builds. -if(DEFINED wgpu_vendor_DIR) +if(NOT _wgpu_target_linked AND DEFINED wgpu_vendor_DIR) get_filename_component( _wgpu_vendor_prefix "${wgpu_vendor_DIR}/../../.." ABSOLUTE) set(_sonar_wgpu_static "${_wgpu_vendor_prefix}/lib/libsonar_wgpu.a") diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index a3aaffe3..74659601 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -49,12 +49,13 @@ #include #include -#include +#include #include #include #include #include #include +#include namespace gz { @@ -173,6 +174,41 @@ class InMemoryTimeVaryingVectorField const GridT * zData{nullptr}; }; +/// \brief Copy an 8-bit OpenCV image into a ROS image message. +/// +/// The sonar only publishes BGR8 and RGB8 visualization images here. Keeping +/// this small conversion local avoids requiring cv_bridge for a byte-for-byte +/// copy and prevents OpenCV major-version coupling at runtime. +void CopyImageToMessage( + const cv::Mat & _image, const std::string & _encoding, sensor_msgs::msg::Image & _message) +{ + _message.height = static_cast(_image.rows); + _message.width = static_cast(_image.cols); + _message.encoding = _encoding; + _message.is_bigendian = false; + _message.step = static_cast(_image.cols * _image.elemSize()); + + const auto dataSize = static_cast(_message.step) * _message.height; + _message.data.resize(dataSize); + if (dataSize == 0) + { + return; + } + + if (_image.isContinuous()) + { + std::memcpy(_message.data.data(), _image.data, dataSize); + return; + } + + for (int row = 0; row < _image.rows; ++row) + { + std::memcpy( + _message.data.data() + static_cast(row) * _message.step, _image.ptr(row), + _message.step); + } +} + } // namespace using namespace gz::msgs; @@ -653,9 +689,10 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo this->pointMsg.set_height(this->raySensor->VerticalRangeCount()); this->pointMsg.set_row_step(this->pointMsg.point_step() * this->pointMsg.width()); - this->rayConnection = this->raySensor->ConnectNewGpuRaysFrame(std::bind( - &MultibeamSonarSensor::Implementation::OnNewFrame, this, std::placeholders::_1, - std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5)); + this->rayConnection = this->raySensor->ConnectNewGpuRaysFrame( + std::bind( + &MultibeamSonarSensor::Implementation::OnNewFrame, this, std::placeholders::_1, + std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5)); // Transmission path properties (typical model used here) // More sophisticated model by Francois-Garrison model is available @@ -1377,9 +1414,6 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() this->sonarRawDataMsg.image = sonar_image_data; this->sonarImageRawPub->publish(this->sonarRawDataMsg); - // Construct visual sonar image for rqt plot in sensor::image msg format - cv_bridge::CvImage img_bridge; - // Generate image of CV_8UC1 cv::Mat Intensity_image = cv::Mat::zeros(cv::Size(this->nBeams, this->nFreq), CV_8UC1); @@ -1468,10 +1502,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() this->sonarImgMsg.header.stamp.nanosec = static_cast(capturedStamp.nanoseconds() % 1000000000); - img_bridge = cv_bridge::CvImage( - this->sonarImgMsg.header, sensor_msgs::image_encodings::BGR8, Itensity_image_color); - // from cv_bridge to sensor_msgs::Image - img_bridge.toImageMsg(this->sonarImgMsg); + CopyImageToMessage(Itensity_image_color, sensor_msgs::image_encodings::BGR8, this->sonarImgMsg); this->sonarImagePub->publish(this->sonarImgMsg); this->normalImgMsg.header.frame_id = this->frameName; @@ -1481,10 +1512,7 @@ void MultibeamSonarSensor::Implementation::ComputeSonarImage() cv::Mat normal_image8; normal_image.convertTo(normal_image8, CV_8UC3, 255.0); - img_bridge = cv_bridge::CvImage( - this->normalImgMsg.header, sensor_msgs::image_encodings::RGB8, normal_image8); - img_bridge.toImageMsg(this->normalImgMsg); - // from cv_bridge to sensor_msgs::Image + CopyImageToMessage(normal_image8, sensor_msgs::image_encodings::RGB8, this->normalImgMsg); this->normalImagePub->publish(this->normalImgMsg); // ---------------------------------------- End of sonar calculation diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/hooks/multibeam_sonar.dsv.in b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/hooks/multibeam_sonar.dsv.in index 07f53dcf..4e8a0b0a 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/hooks/multibeam_sonar.dsv.in +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/hooks/multibeam_sonar.dsv.in @@ -1,2 +1,3 @@ prepend-non-duplicate;GZ_SIM_SYSTEM_PLUGIN_PATH;lib/@PROJECT_NAME@/ prepend-non-duplicate;LD_LIBRARY_PATH;lib/@PROJECT_NAME@/ +prepend-non-duplicate;DYLD_LIBRARY_PATH;lib/@PROJECT_NAME@/ diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml index fb032e84..6095f870 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml @@ -12,7 +12,6 @@ rclcpp sensor_msgs geometry_msgs - cv_bridge image_transport marine_acoustic_msgs pcl_conversions @@ -23,4 +22,4 @@ ament_cmake - \ No newline at end of file + diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/hooks/multibeam_sonar_system.dsv.in b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/hooks/multibeam_sonar_system.dsv.in index 07f53dcf..1af9a9a4 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/hooks/multibeam_sonar_system.dsv.in +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/hooks/multibeam_sonar_system.dsv.in @@ -1,2 +1,5 @@ prepend-non-duplicate;GZ_SIM_SYSTEM_PLUGIN_PATH;lib/@PROJECT_NAME@/ prepend-non-duplicate;LD_LIBRARY_PATH;lib/@PROJECT_NAME@/ +prepend-non-duplicate;LD_LIBRARY_PATH;lib/multibeam_sonar/ +prepend-non-duplicate;DYLD_LIBRARY_PATH;lib/@PROJECT_NAME@/ +prepend-non-duplicate;DYLD_LIBRARY_PATH;lib/multibeam_sonar/ From 720eb3d0573a6fffc9c2c9195cbda03f299eaafe Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Fri, 18 Sep 2026 02:34:19 +0000 Subject: [PATCH 12/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../multibeam_sonar/MultibeamSonarSensor.cc | 7 +++---- gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index 74659601..c7a6b901 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -689,10 +689,9 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo this->pointMsg.set_height(this->raySensor->VerticalRangeCount()); this->pointMsg.set_row_step(this->pointMsg.point_step() * this->pointMsg.width()); - this->rayConnection = this->raySensor->ConnectNewGpuRaysFrame( - std::bind( - &MultibeamSonarSensor::Implementation::OnNewFrame, this, std::placeholders::_1, - std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5)); + this->rayConnection = this->raySensor->ConnectNewGpuRaysFrame(std::bind( + &MultibeamSonarSensor::Implementation::OnNewFrame, this, std::placeholders::_1, + std::placeholders::_2, std::placeholders::_3, std::placeholders::_4, std::placeholders::_5)); // Transmission path properties (typical model used here) // More sophisticated model by Francois-Garrison model is available diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml index 6095f870..5cbcbcb8 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/package.xml @@ -22,4 +22,4 @@ ament_cmake - + \ No newline at end of file From a9be8055b22f0b76c1953eaca801770c54b51b91 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 12:34:51 +0900 Subject: [PATCH 13/18] fix: resolve multibeam sonar CMake lint failures --- .../dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt | 3 ++- .../multibeam_sonar_demo/CMakeLists.txt | 4 ++-- gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt | 5 ++++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index f4000d7f..04358394 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -35,7 +35,8 @@ if(CUDAToolkit_FOUND) enable_language(CUDA) find_package(CUDA REQUIRED) message(STATUS - "CUDA ${CUDAToolkit_VERSION} found (SM ${CUDA_ARCHITECTURE}), enabling CUDA support.") + "CUDA ${CUDAToolkit_VERSION} found (SM ${CUDA_ARCHITECTURE}), " + "enabling CUDA support.") include_directories(${CUDA_INCLUDE_DIRS}) endif() diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt index 04e9178f..72043996 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_demo/CMakeLists.txt @@ -19,7 +19,7 @@ install( DESTINATION lib/${PROJECT_NAME} ) - ament_environment_hooks( - "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") +ament_environment_hooks( + "${CMAKE_CURRENT_SOURCE_DIR}/hooks/${PROJECT_NAME}.dsv.in") ament_package() diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt index 3a28cf79..3cf9e07a 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt @@ -30,7 +30,10 @@ add_custom_command( VERBATIM ) -add_custom_target(sonar_wgpu_rust ALL DEPENDS ${RUST_LIB}) +add_custom_target(sonar_wgpu_rust ALL + DEPENDS ${RUST_LIB} + COMMENT "Ensuring sonar_wgpu Rust static library is built" +) add_library(sonar_wgpu_rust_static STATIC IMPORTED GLOBAL) set_target_properties(sonar_wgpu_rust_static PROPERTIES From 6908e81a9bfc731d3b00e3aff9c7d769ef95b0ab Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 12:41:28 +0900 Subject: [PATCH 14/18] ci: run Docker builds for all ros2 pull requests --- .github/workflows/docker-amd64.yml | 22 ++++++---------------- .github/workflows/docker-arm64v8.yml | 22 ++++++---------------- 2 files changed, 12 insertions(+), 32 deletions(-) diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml index c9382f67..125db20b 100644 --- a/.github/workflows/docker-amd64.yml +++ b/.github/workflows/docker-amd64.yml @@ -6,16 +6,6 @@ on: workflow_dispatch: pull_request: branches: [ros2] - paths: - - ".docker/**" - - ".dockerignore" - - ".github/workflows/docker-amd64.yml" - - "dave_interfaces/**" - - "examples/**" - - "extras/**" - - "gazebo/**" - - "models/**" - - "README.md" push: tags: ["*"] branches: [ros2, dockertest] @@ -30,12 +20,12 @@ concurrency: jobs: build-and-push-to-docker-hub: - # Never execute untrusted fork code on a self-hosted runner. - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, x64] - timeout-minutes: 1440 + # Validate all PRs on disposable hosted runners, including fork PRs. + # Keep publishing builds on the existing self-hosted runner. + runs-on: >- + ${{ fromJSON(github.event_name == 'pull_request' + && '["ubuntu-24.04"]' || '["self-hosted", "x64"]') }} + timeout-minutes: ${{ github.event_name == 'pull_request' && 360 || 1440 }} permissions: contents: read packages: write diff --git a/.github/workflows/docker-arm64v8.yml b/.github/workflows/docker-arm64v8.yml index 192ddff1..ae3923c7 100644 --- a/.github/workflows/docker-arm64v8.yml +++ b/.github/workflows/docker-arm64v8.yml @@ -6,16 +6,6 @@ on: workflow_dispatch: pull_request: branches: [ros2] - paths: - - ".docker/**" - - ".dockerignore" - - ".github/workflows/docker-arm64v8.yml" - - "dave_interfaces/**" - - "examples/**" - - "extras/**" - - "gazebo/**" - - "models/**" - - "README.md" push: tags: ["*"] branches: [ros2, dockertest] @@ -30,12 +20,12 @@ concurrency: jobs: build-and-push-to-docker-hub: - # Never execute untrusted fork code on a self-hosted runner. - if: >- - github.event_name != 'pull_request' || - github.event.pull_request.head.repo.full_name == github.repository - runs-on: [self-hosted, ARM64] - timeout-minutes: 1440 + # Validate all PRs on disposable hosted runners, including fork PRs. + # Keep publishing builds on the existing self-hosted runner. + runs-on: >- + ${{ fromJSON(github.event_name == 'pull_request' + && '["ubuntu-24.04-arm"]' || '["self-hosted", "ARM64"]') }} + timeout-minutes: ${{ github.event_name == 'pull_request' && 360 || 1440 }} permissions: contents: read packages: write From 272d8a0d78091a4386af2de89bed2dc8f0ab533d Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:27:44 +0900 Subject: [PATCH 15/18] fix: order sonar builds and export the installed WGPU library --- .docker/lyrical.arm64v8.dockerfile | 2 +- .../multibeam_sonar_system/package.xml | 3 ++- .../wgpu_vendor/CMakeLists.txt | 21 ++++++++----------- .../wgpu_vendor/package.xml | 4 +++- 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/.docker/lyrical.arm64v8.dockerfile b/.docker/lyrical.arm64v8.dockerfile index b4eada52..832f83de 100644 --- a/.docker/lyrical.arm64v8.dockerfile +++ b/.docker/lyrical.arm64v8.dockerfile @@ -6,7 +6,7 @@ # setup below is the adapted version of what that base image contained, plus the xrdp # group-permission fix required by the verified Apple Silicon build. # - ROS_DISTRO=lyrical; ros-lyrical-ros-gz supplies the Gazebo Jetty vendor packages. -# - The current CUDA sonar remains disabled on ARM64; the open WGPU PR is intentionally excluded. +# - The sonar uses the portable WGPU backend when CUDA is unavailable on ARM64. # - ArduSub SITL includes the Python 3.14 compatibility shims validated on Ubuntu 26.04 # (imp/pipes modules, python-argparse removal, and PEP 668 handling). diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml index 0f7f9b54..ddb1d11a 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml @@ -10,8 +10,9 @@ Apache 2.0 geometry_msgs ament_cmake + multibeam_sonar ament_lint_auto ament_cmake - \ No newline at end of file + diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt index 3cf9e07a..a7b98525 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/CMakeLists.txt @@ -35,15 +35,15 @@ add_custom_target(sonar_wgpu_rust ALL COMMENT "Ensuring sonar_wgpu Rust static library is built" ) -add_library(sonar_wgpu_rust_static STATIC IMPORTED GLOBAL) -set_target_properties(sonar_wgpu_rust_static PROPERTIES - IMPORTED_LOCATION ${RUST_LIB}) -add_dependencies(sonar_wgpu_rust_static sonar_wgpu_rust) - add_library(wgpu_vendor INTERFACE) -add_dependencies(wgpu_vendor sonar_wgpu_rust_static) +add_dependencies(wgpu_vendor sonar_wgpu_rust) -target_link_libraries(wgpu_vendor INTERFACE sonar_wgpu_rust_static dl pthread m) +# Export the installed archive, not an imported target from this build tree. +target_link_libraries(wgpu_vendor INTERFACE + "$" + "$/lib/libsonar_wgpu.a>" + dl pthread m +) if(Vulkan_FOUND) target_link_libraries(wgpu_vendor INTERFACE Vulkan::Vulkan) endif() @@ -53,12 +53,9 @@ add_library(wgpu::wgpu_vendor ALIAS wgpu_vendor) install(FILES ${RUST_LIB} DESTINATION lib) install(TARGETS wgpu_vendor EXPORT wgpu_vendorTargets) -install(EXPORT wgpu_vendorTargets - NAMESPACE wgpu:: - FILE wgpu_vendorTargets.cmake - DESTINATION share/${PROJECT_NAME}/cmake) -ament_export_targets(wgpu_vendorTargets HAS_LIBRARY_TARGET) +# Match the public wgpu::wgpu_vendor target used by the sonar sensor. +ament_export_targets(wgpu_vendorTargets HAS_LIBRARY_TARGET NAMESPACE wgpu::) if(Vulkan_FOUND) ament_export_dependencies(Vulkan) endif() diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml index 1fdbb454..d6a58165 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml @@ -6,7 +6,9 @@ DAVE Maintainers Apache-2.0 ament_cmake + cargo + rustc ament_cmake - \ No newline at end of file + From 5253159d5301e7f3f2dd823e26798ca7bb787599 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:27:44 +0900 Subject: [PATCH 16/18] ci: use Ubuntu 26.04 and publish PR-number Docker images --- .github/workflows/docker-amd64.yml | 16 ++- .github/workflows/docker-arm64v8.yml | 18 ++- .github/workflows/docker-pr-publish.yml | 173 ++++++++++++++++++++++++ 3 files changed, 202 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/docker-pr-publish.yml diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml index 125db20b..2f0ec2f7 100644 --- a/.github/workflows/docker-amd64.yml +++ b/.github/workflows/docker-amd64.yml @@ -24,11 +24,10 @@ jobs: # Keep publishing builds on the existing self-hosted runner. runs-on: >- ${{ fromJSON(github.event_name == 'pull_request' - && '["ubuntu-24.04"]' || '["self-hosted", "x64"]') }} + && '["ubuntu-26.04"]' || '["self-hosted", "x64"]') }} timeout-minutes: ${{ github.event_name == 'pull_request' && 360 || 1440 }} permissions: contents: read - packages: write steps: - name: Checkout repository @@ -60,9 +59,22 @@ jobs: with: context: . push: ${{ github.event_name != 'pull_request' }} + outputs: >- + ${{ github.event_name == 'pull_request' + && format('type=oci,dest={0}/pr-image.tar', runner.temp) || '' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} file: .docker/lyrical.amd64.dockerfile platforms: linux/amd64 build-args: | ROS_DISTRO=${{ env.ROS_DISTRO }} + + - name: Upload PR image for trusted publishing + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: dave-pr-image-amd64 + path: ${{ runner.temp }}/pr-image.tar + if-no-files-found: error + compression-level: 0 + retention-days: 3 diff --git a/.github/workflows/docker-arm64v8.yml b/.github/workflows/docker-arm64v8.yml index ae3923c7..8ebddee1 100644 --- a/.github/workflows/docker-arm64v8.yml +++ b/.github/workflows/docker-arm64v8.yml @@ -24,11 +24,10 @@ jobs: # Keep publishing builds on the existing self-hosted runner. runs-on: >- ${{ fromJSON(github.event_name == 'pull_request' - && '["ubuntu-24.04-arm"]' || '["self-hosted", "ARM64"]') }} + && '["ubuntu-26.04-arm"]' || '["self-hosted", "ARM64"]') }} timeout-minutes: ${{ github.event_name == 'pull_request' && 360 || 1440 }} permissions: contents: read - packages: write steps: - name: Configure non-interactive Docker credentials run: | @@ -47,7 +46,7 @@ jobs: # open Docker Desktop's interactive Keychain credential helper. # The native driver avoids that dependency for this single-platform # ARM64 build and works with Docker Desktop's containerd image store. - driver: docker + driver: ${{ github.event_name == 'pull_request' && 'docker-container' || 'docker' }} - name: Configure Docker Hub authentication if: github.event_name != 'pull_request' @@ -85,6 +84,9 @@ jobs: with: context: . push: ${{ github.event_name != 'pull_request' }} + outputs: >- + ${{ github.event_name == 'pull_request' + && format('type=oci,dest={0}/pr-image.tar', runner.temp) || '' }} tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} file: .docker/lyrical.arm64v8.dockerfile @@ -92,6 +94,16 @@ jobs: build-args: | ROS_DISTRO=${{ env.ROS_DISTRO }} + - name: Upload PR image for trusted publishing + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v4 + with: + name: dave-pr-image-arm64 + path: ${{ runner.temp }}/pr-image.tar + if-no-files-found: error + compression-level: 0 + retention-days: 3 + - name: Clear Docker Hub credentials if: always() && github.event_name != 'pull_request' run: printf '{}\n' > "$DOCKER_CONFIG/config.json" diff --git a/.github/workflows/docker-pr-publish.yml b/.github/workflows/docker-pr-publish.yml new file mode 100644 index 00000000..bb3a628d --- /dev/null +++ b/.github/workflows/docker-pr-publish.yml @@ -0,0 +1,173 @@ +--- +name: Publish PR Docker images + +# This workflow must be on ros2 (the default branch) to receive workflow_run. +# Never check out or execute PR code here: only copy completed image archives. +# yamllint disable-line rule:truthy +on: + workflow_run: + workflows: + - Build Lyrical / Jetty Docker image (AMD64) + - Build Lyrical / Jetty Docker image (ARM64) + types: [completed] + workflow_dispatch: + inputs: + run_id: + description: Successful PR Docker build run ID to publish + required: true + type: string + +permissions: + actions: read + contents: read + pull-requests: read + +concurrency: + group: publish-pr-docker-images + cancel-in-progress: false + +jobs: + identify: + if: >- + github.event_name == 'workflow_dispatch' || + (github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.conclusion == 'success') + runs-on: ubuntu-26.04 + outputs: + ready: ${{ steps.builds.outputs.ready }} + pr: ${{ steps.builds.outputs.pr }} + sha: ${{ steps.builds.outputs.sha }} + amd64_run: ${{ steps.builds.outputs.amd64_run }} + arm64_run: ${{ steps.builds.outputs.arm64_run }} + steps: + - name: Verify current PR and successful builds for both architectures + id: builds + uses: actions/github-script@v7 + with: + script: | + const repo = context.repo; + const id = String(context.payload.workflow_run?.id || context.payload.inputs?.run_id || ''); + if (!/^\d+$/.test(id)) throw new Error('Invalid build run ID'); + const {data: source} = await github.rest.actions.getWorkflowRun({...repo, run_id: Number(id)}); + const files = {amd64: 'docker-amd64.yml', arm64: 'docker-arm64v8.yml'}; + if (source.event !== 'pull_request' || source.conclusion !== 'success' || + !Object.values(files).some(file => source.path === `.github/workflows/${file}`)) { + throw new Error('Expected a successful PR Docker build in this repository'); + } + // Fork runs can have an empty pull_requests array. Resolve the PR via the API. + const prs = await github.paginate(github.rest.pulls.list, { + ...repo, state: 'open', base: 'ros2', + head: `${source.head_repository.owner.login}:${source.head_branch}`, per_page: 100 + }); + const matches = prs.filter(pr => pr.head.sha === source.head_sha && + pr.head.repo?.full_name === source.head_repository.full_name); + if (matches.length !== 1) { + core.notice('Build is no longer the current head of an open ros2 PR'); + return; + } + const selected = {}; + for (const [arch, workflow_id] of Object.entries(files)) { + const {data} = await github.rest.actions.listWorkflowRuns({ + ...repo, workflow_id, event: 'pull_request', head_sha: source.head_sha, + status: 'success', per_page: 100 + }); + const run = data.workflow_runs.find(candidate => + candidate.head_repository?.full_name === source.head_repository.full_name); + if (!run) { + core.notice(`Waiting for a successful ${arch} build of this revision`); + return; + } + const artifacts = await github.paginate(github.rest.actions.listWorkflowRunArtifacts, { + ...repo, run_id: run.id, per_page: 100 + }); + if (!artifacts.some(item => item.name === `dave-pr-image-${arch}` && !item.expired)) { + throw new Error(`Missing ${arch} image artifact in run ${run.id}`); + } + selected[arch] = run.id; + } + core.setOutput('pr', matches[0].number); + core.setOutput('sha', source.head_sha); + core.setOutput('amd64_run', selected.amd64); + core.setOutput('arm64_run', selected.arm64); + core.setOutput('ready', 'true'); + + publish: + needs: identify + if: needs.identify.outputs.ready == 'true' + runs-on: ubuntu-26.04 + timeout-minutes: 120 + env: + IMAGE_NAME: ioeslab/dave + PR_NUMBER: ${{ needs.identify.outputs.pr }} + SOURCE_SHA: ${{ needs.identify.outputs.sha }} + steps: + - name: Install image-copy tool + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends skopeo + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Download AMD64 image from its verified build + uses: actions/download-artifact@v4 + with: + name: dave-pr-image-amd64 + path: ${{ runner.temp }}/amd64 + run-id: ${{ needs.identify.outputs.amd64_run }} + github-token: ${{ github.token }} + + - name: Publish AMD64 image without running it + run: | + archive="$RUNNER_TEMP/amd64/pr-image.tar" + skopeo inspect --config "oci-archive:$archive" | \ + jq -e '.os == "linux" and .architecture == "amd64"' + skopeo copy --all --authfile "$HOME/.docker/config.json" \ + "oci-archive:$archive" "docker://$IMAGE_NAME:pr-$PR_NUMBER-amd64-$SOURCE_SHA" + rm -- "$archive" + + - name: Download ARM64 image from its verified build + uses: actions/download-artifact@v4 + with: + name: dave-pr-image-arm64 + path: ${{ runner.temp }}/arm64 + run-id: ${{ needs.identify.outputs.arm64_run }} + github-token: ${{ github.token }} + + - name: Publish ARM64 image without running it + run: | + archive="$RUNNER_TEMP/arm64/pr-image.tar" + skopeo inspect --override-arch arm64 --config "oci-archive:$archive" | \ + jq -e '.os == "linux" and .architecture == "arm64"' + skopeo copy --all --authfile "$HOME/.docker/config.json" \ + "oci-archive:$archive" "docker://$IMAGE_NAME:pr-$PR_NUMBER-arm64-$SOURCE_SHA" + rm -- "$archive" + + - name: Check that the PR still points to this revision + uses: actions/github-script@v7 + with: + script: | + const {data: pr} = await github.rest.pulls.get({ + ...context.repo, pull_number: Number(process.env.PR_NUMBER) + }); + if (pr.state !== 'open' || pr.base.ref !== 'ros2' || pr.head.sha !== process.env.SOURCE_SHA) { + throw new Error('PR changed during publishing; leaving its current tags unchanged'); + } + + - name: Publish PR-number tags and multi-platform manifest + run: | + docker buildx imagetools create --tag "$IMAGE_NAME:pr-$PR_NUMBER-amd64" \ + "$IMAGE_NAME:pr-$PR_NUMBER-amd64-$SOURCE_SHA" + docker buildx imagetools create --tag "$IMAGE_NAME:pr-$PR_NUMBER-arm64" \ + "$IMAGE_NAME:pr-$PR_NUMBER-arm64-$SOURCE_SHA" + docker buildx imagetools create --tag "$IMAGE_NAME:pr-$PR_NUMBER" \ + "$IMAGE_NAME:pr-$PR_NUMBER-amd64-$SOURCE_SHA" \ + "$IMAGE_NAME:pr-$PR_NUMBER-arm64-$SOURCE_SHA" + printf 'Published `%s:pr-%s` for `%s` (AMD64 + ARM64).\n' \ + "$IMAGE_NAME" "$PR_NUMBER" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY" From 997c83f21744876bc560cd32c4db27f8463b7891 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 04:28:04 +0000 Subject: [PATCH 17/18] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml | 2 +- gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml index ddb1d11a..4563ae4f 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/package.xml @@ -15,4 +15,4 @@ ament_cmake - + \ No newline at end of file diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml index d6a58165..691fc516 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml @@ -11,4 +11,4 @@ ament_cmake - + \ No newline at end of file From c4139b5e86698c2542c3af30290725807d4339f9 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:41:14 +0900 Subject: [PATCH 18/18] fix: make ROS bootstrap downloads deterministic in Docker builds --- .docker/lyrical.arm64v8.dockerfile | 6 ++++-- .github/workflows/docker-pr-publish.yml | 2 +- extras/ros-lyrical-gz-jetty-install.sh | 6 ++++-- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/.docker/lyrical.arm64v8.dockerfile b/.docker/lyrical.arm64v8.dockerfile index 832f83de..bace0264 100644 --- a/.docker/lyrical.arm64v8.dockerfile +++ b/.docker/lyrical.arm64v8.dockerfile @@ -96,13 +96,15 @@ EXPOSE 22/tcp # --- ROS 2 Lyrical + Gazebo Jetty --- ARG ROS_DISTRO="lyrical" +ARG ROS_APT_SOURCE_VERSION="1.3.0" ENV GZ_VERSION=jetty RUN apt update && apt full-upgrade -y && apt autoremove -y # Gazebo Jetty is vendored by ros-lyrical-ros-gz on apt already — no separate Gazebo source build -RUN export ROS_APT_SOURCE_VERSION=$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | grep -F "tag_name" | awk -F\" '{print $4}') && \ - curl -L -o /tmp/ros2-apt-source.deb \ +# Pin the bootstrap package to avoid unauthenticated GitHub API rate limits. +RUN curl --fail --show-error --location --retry 5 --retry-delay 2 \ + -o /tmp/ros2-apt-source.deb \ "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.$(. /etc/os-release && echo ${UBUNTU_CODENAME:-${VERSION_CODENAME}})_all.deb" && \ dpkg -i /tmp/ros2-apt-source.deb && \ apt update && \ diff --git a/.github/workflows/docker-pr-publish.yml b/.github/workflows/docker-pr-publish.yml index bb3a628d..0cfe46bd 100644 --- a/.github/workflows/docker-pr-publish.yml +++ b/.github/workflows/docker-pr-publish.yml @@ -169,5 +169,5 @@ jobs: docker buildx imagetools create --tag "$IMAGE_NAME:pr-$PR_NUMBER" \ "$IMAGE_NAME:pr-$PR_NUMBER-amd64-$SOURCE_SHA" \ "$IMAGE_NAME:pr-$PR_NUMBER-arm64-$SOURCE_SHA" - printf 'Published `%s:pr-%s` for `%s` (AMD64 + ARM64).\n' \ + printf 'Published %s:pr-%s for %s (AMD64 + ARM64).\n' \ "$IMAGE_NAME" "$PR_NUMBER" "$SOURCE_SHA" >> "$GITHUB_STEP_SUMMARY" diff --git a/extras/ros-lyrical-gz-jetty-install.sh b/extras/ros-lyrical-gz-jetty-install.sh index 6f0eff6c..ff8a36ba 100755 --- a/extras/ros-lyrical-gz-jetty-install.sh +++ b/extras/ros-lyrical-gz-jetty-install.sh @@ -55,8 +55,10 @@ sudo apt update && sudo apt install -y jq # shellcheck disable=SC1091 . /etc/os-release UBUNTU_CODENAME="${UBUNTU_CODENAME:-${VERSION_CODENAME}}" -ROS_APT_SOURCE_VERSION=$(curl -s https://api.github.com/repos/ros-infrastructure/ros-apt-source/releases/latest | jq -r '.tag_name') && \ - curl -L -o /tmp/ros2-apt-source.deb \ +# Use a known release instead of a rate-limited unauthenticated API lookup. +ROS_APT_SOURCE_VERSION="${ROS_APT_SOURCE_VERSION:-1.3.0}" +curl --fail --show-error --location --retry 5 --retry-delay 2 \ + -o /tmp/ros2-apt-source.deb \ "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ROS_APT_SOURCE_VERSION}/ros2-apt-source_${ROS_APT_SOURCE_VERSION}.${UBUNTU_CODENAME}_all.deb" && \ sudo apt-get install -y /tmp/ros2-apt-source.deb && \ rm -f /tmp/ros2-apt-source.deb