From d6554411641b9ca8a56f2842208fcef4942c174b Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Tue, 17 Mar 2026 02:03:11 +0530 Subject: [PATCH 01/31] 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/31] [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/31] 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/31] [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/31] 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/31] 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/31] 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/31] 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/31] 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/31] 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 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 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 12a4272ac49441816eaa7669aed0ff2a361e195f Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 15:50:02 +0900 Subject: [PATCH 11/31] ci: fix runtime, permission, and formatting annotations --- .github/workflows/docker-amd64.yml | 10 ++-- .github/workflows/docker-arm64v8.yml | 8 +-- .github/workflows/docker-pr-publish.yml | 12 ++-- .github/workflows/lint.yml | 58 +++++++++---------- .github/workflows/pages.yml | 56 ++++++++++++++++++ .../config/mavros/mavros.yaml | 1 + 6 files changed, 100 insertions(+), 45 deletions(-) create mode 100644 .github/workflows/pages.yml diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml index c9382f67..a15f8fe3 100644 --- a/.github/workflows/docker-amd64.yml +++ b/.github/workflows/docker-amd64.yml @@ -42,21 +42,21 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to Docker Hub if: github.event_name != 'pull_request' - uses: docker/login-action@v4 + uses: docker/login-action@v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6.2.0 with: images: ${{ env.IMAGE_NAME }} tags: | @@ -66,7 +66,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} - name: Build Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7.4.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/docker-arm64v8.yml b/.github/workflows/docker-arm64v8.yml index 192ddff1..70252a75 100644 --- a/.github/workflows/docker-arm64v8.yml +++ b/.github/workflows/docker-arm64v8.yml @@ -48,10 +48,10 @@ jobs: echo "DOCKER_CONFIG=$docker_config" >> "$GITHUB_ENV" - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v7.0.1 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.4.1 with: # On a macOS launchd runner, the docker-container driver can try to # open Docker Desktop's interactive Keychain credential helper. @@ -83,7 +83,7 @@ jobs: - name: Docker metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@v6.2.0 with: images: ${{ env.IMAGE_NAME }} tags: | @@ -91,7 +91,7 @@ jobs: type=ref,event=pr - name: Build Docker image - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v7.4.0 with: context: . push: ${{ github.event_name != 'pull_request' }} diff --git a/.github/workflows/docker-pr-publish.yml b/.github/workflows/docker-pr-publish.yml index 0cfe46bd..1b5c8f8b 100644 --- a/.github/workflows/docker-pr-publish.yml +++ b/.github/workflows/docker-pr-publish.yml @@ -42,7 +42,7 @@ jobs: steps: - name: Verify current PR and successful builds for both architectures id: builds - uses: actions/github-script@v7 + uses: actions/github-script@v9.0.0 with: script: | const repo = context.repo; @@ -107,16 +107,16 @@ jobs: sudo apt-get install -y --no-install-recommends skopeo - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@v4.4.1 - name: Log in to Docker Hub - uses: docker/login-action@v4 + uses: docker/login-action@v4.6.0 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Download AMD64 image from its verified build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: name: dave-pr-image-amd64 path: ${{ runner.temp }}/amd64 @@ -133,7 +133,7 @@ jobs: rm -- "$archive" - name: Download ARM64 image from its verified build - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8.0.1 with: name: dave-pr-image-arm64 path: ${{ runner.temp }}/arm64 @@ -150,7 +150,7 @@ jobs: rm -- "$archive" - name: Check that the PR still points to this revision - uses: actions/github-script@v7 + uses: actions/github-script@v9.0.0 with: script: | const {data: pr} = await github.rest.pulls.get({ diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 640fdefd..d633ad8f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -10,62 +10,59 @@ on: pull_request: types: [opened, synchronize, reopened] -jobs: - # ament_lint: - # runs-on: ubuntu-latest - # container: - # image: rostooling/setup-ros-docker:ubuntu-resolute-ros-lyrical-desktop-latest - # options: -u root - # strategy: - # fail-fast: false - # matrix: - # linter: [flake8, mypy, pep8, xmllint] - # steps: - # - uses: actions/checkout@v2 - # - uses: ros-tooling/action-ros-lint@master - # with: - # linter: ${{ matrix.linter }} - # package-name: . +permissions: + contents: read +jobs: run-linters: name: Run Linters for Code Format Check - runs-on: ubuntu-24.04 + runs-on: ubuntu-26.04 steps: - name: Check out Git repository - uses: actions/checkout@v3 + uses: actions/checkout@v7.0.1 - name: Set up Python 3.12 - uses: actions/setup-python@v1 + uses: actions/setup-python@v7.0.0 with: python-version: 3.12 - name: Install Python dependencies - run: pip install black flake8 cmakelang + # Match the formatter versions used by .pre-commit-config.yaml. + run: >- + python -m pip install black==24.10.0 flake8==7.1.1 + clang-format==19.1.6 cmakelang==0.6.13 yamllint==1.35.1 - name: Install system lint dependencies run: | sudo apt-get update - sudo apt-get install -y clang-format libxml2-utils + sudo apt-get install -y libxml2-utils - - name: Run Linters for python and C++ - uses: wearerequired/lint-action@v2 - with: - black: true - black_args: --line-length 100 - flake8: true - flake8_args: --max-line-length=100 - clang_format: true + - name: Check Python formatting + run: black --check --diff --line-length 99 --exclude tools/code_check . + + - name: Lint Python + if: ${{ !cancelled() }} + run: flake8 --ignore=E203,W503,E501 --exclude=tools/code_check . + + - name: Check C and C++ formatting + if: ${{ !cancelled() }} + run: | + git ls-files -z '*.c' '*.cc' '*.cpp' '*.h' '*.hpp' '*.m' '*.mm' | \ + xargs -0 -r clang-format --dry-run --Werror --fallback-style=none - name: Run XML Linter for URDF, SDF, Xacro, XML files + if: ${{ !cancelled() }} run: | find . \( -iname '*.urdf' -o -iname '*.sdf' -o -iname '*.xacro' \ -o -iname '*.xml' -o -iname '*.launch' -o -iname '*.world' \) -print0 | xargs -0 xmllint --noout - name: Run ShellCheck - uses: ludeeus/action-shellcheck@master + if: ${{ !cancelled() }} + uses: ludeeus/action-shellcheck@2.0.0 - name: Run Yamllint on YAML and .launch files + if: ${{ !cancelled() }} run: | find . \( -iname '*.yml' -o -iname '*.yaml' \) -print0 | xargs -0 yamllint -d "{ extends: default, @@ -76,4 +73,5 @@ jobs: }" - name: Lint CMake files + if: ${{ !cancelled() }} run: find . \( -iname 'CMakeLists.txt' -o -iname '*.cmake' \) -print0 | xargs -0 cmake-lint diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml new file mode 100644 index 00000000..cf0df750 --- /dev/null +++ b/.github/workflows/pages.yml @@ -0,0 +1,56 @@ +--- +name: Build and deploy Pages + +# After merging, select Settings > Pages > Source > GitHub Actions to retire +# GitHub's generated legacy workflow and its uneditable action versions. +# yamllint disable-line rule:truthy +on: + push: + branches: [ros2] + pull_request: + branches: [ros2] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: pages-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: false + +jobs: + build: + runs-on: ubuntu-26.04 + permissions: + contents: read + pages: read + steps: + - name: Check out repository + uses: actions/checkout@v7.0.1 + + - name: Configure Pages + uses: actions/configure-pages@v6.0.0 + + - name: Build the existing Jekyll site from the repository root + uses: actions/jekyll-build-pages@v1.0.13 + with: + source: ./ + destination: ./_site + + - name: Upload Pages artifact + uses: actions/upload-pages-artifact@v5.0.0 + + deploy: + if: github.event_name != 'pull_request' && github.ref == 'refs/heads/ros2' + needs: build + runs-on: ubuntu-26.04 + permissions: + pages: write + id-token: write + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} + steps: + - name: Deploy Pages + id: deployment + uses: actions/deploy-pages@v5.0.1 diff --git a/models/dave_robot_models/config/mavros/mavros.yaml b/models/dave_robot_models/config/mavros/mavros.yaml index 0e0de031..a3ea5c74 100644 --- a/models/dave_robot_models/config/mavros/mavros.yaml +++ b/models/dave_robot_models/config/mavros/mavros.yaml @@ -1,3 +1,4 @@ +--- mavros: ros__parameters: system_id: 255 From 56311df0c3bd8250a7f4c770e4cc0845333f73a0 Mon Sep 17 00:00:00 2001 From: CHOI WOEN-SUG Date: Tue, 29 Sep 2026 09:49:56 +0900 Subject: [PATCH 12/31] Refactor Docker connection and credentials setup Updated Docker configuration step for better clarity and error handling. --- .github/workflows/docker-arm64v8.yml | 29 +++++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-arm64v8.yml b/.github/workflows/docker-arm64v8.yml index 70252a75..ef4f6edc 100644 --- a/.github/workflows/docker-arm64v8.yml +++ b/.github/workflows/docker-arm64v8.yml @@ -40,12 +40,31 @@ jobs: contents: read packages: write steps: - - name: Configure non-interactive Docker credentials + - name: Configure Docker connection and credentials + shell: bash run: | - docker_config="$RUNNER_TEMP/docker-config" - mkdir -p "$docker_config" - printf '{}\n' > "$docker_config/config.json" - echo "DOCKER_CONFIG=$docker_config" >> "$GITHUB_ENV" + set -euo pipefail + + export DOCKER_CONFIG="$RUNNER_TEMP/docker-config" + export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock" + + mkdir -p "$DOCKER_CONFIG" + chmod 700 "$DOCKER_CONFIG" + printf '{}\n' > "$DOCKER_CONFIG/config.json" + chmod 600 "$DOCKER_CONFIG/config.json" + + { + echo "DOCKER_CONFIG=$DOCKER_CONFIG" + echo "DOCKER_HOST=$DOCKER_HOST" + } >> "$GITHUB_ENV" + + if [ ! -S "$HOME/.docker/run/docker.sock" ]; then + echo "::error::Docker Desktop socket not found. Start Docker Desktop as the runner user." + exit 1 + fi + + docker version + docker info - name: Checkout repository uses: actions/checkout@v7.0.1 From cfae083f4468c369fe002418d913cb5a5612a9bc Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Tue, 29 Sep 2026 14:16:37 +0900 Subject: [PATCH 13/31] ci: fetch pinned ArduPilot sources with bounded retries --- .docker/lyrical.arm64v8.dockerfile | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/.docker/lyrical.arm64v8.dockerfile b/.docker/lyrical.arm64v8.dockerfile index b4eada52..85a0eb31 100644 --- a/.docker/lyrical.arm64v8.dockerfile +++ b/.docker/lyrical.arm64v8.dockerfile @@ -141,9 +141,25 @@ USER root ARG ARDUSUB_COMMIT="30257f01185471ab4c1ac544e47d1b4437e44c98" ARG ARDUPILOT_GAZEBO_COMMIT="082a0fe231f6e63bc8d1598f1cba461d9e2ea7f5" WORKDIR /home/$USER -RUN git clone --recurse-submodules https://github.com/ArduPilot/ardupilot.git && \ - cd ardupilot && git fetch --tags && git checkout --detach "$ARDUSUB_COMMIT" && \ - git submodule update --init --recursive +# Fetch only the pinned revision before its submodules, avoiding a full-history +# clone of the default branch. Retry interrupted transfers without changing pins. +RUN set -eu; \ + retry() { \ + for attempt in 1 2 3; do \ + if "$@"; then return 0; fi; \ + if [ "$attempt" -lt 3 ]; then \ + echo "Git transfer failed (attempt $attempt/3); retrying..." >&2; \ + sleep "$((attempt * 5))"; \ + fi; \ + done; \ + return 1; \ + }; \ + git init ardupilot; \ + cd ardupilot; \ + git remote add origin https://github.com/ArduPilot/ardupilot.git; \ + retry git fetch --depth 1 --no-tags origin "$ARDUSUB_COMMIT"; \ + git checkout --detach "$ARDUSUB_COMMIT"; \ + retry git submodule update --init --recursive --depth 1 RUN mkdir -p /home/$USER/imp_shim && \ printf 'import types\ndef new_module(name):\n return types.ModuleType(name)\n' > /home/$USER/imp_shim/imp.py && \ From cc2fd14239082be8e4880d6d138cccb2abba704a 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 14/31] 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 bb5c8eebfc98ccf3e298a857a6dd5514735c0e6c 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 15/31] [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 e388e9f9e6638e1946d768698cb4734a182fc939 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 12:34:51 +0900 Subject: [PATCH 16/31] 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 f7def31cd80626fd348dc449eebfd9e45d318a52 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 12:41:28 +0900 Subject: [PATCH 17/31] 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 a15f8fe3..4836f95a 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 ef4f6edc..eb857b47 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 156e429341439147ef750bdd56f0e2436effbd30 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:27:44 +0900 Subject: [PATCH 18/31] 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 85a0eb31..db482997 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 54871574b6b21a719a7e2059224042a70f1a4e22 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:27:44 +0900 Subject: [PATCH 19/31] ci: use Ubuntu 26.04 and publish PR-number Docker images --- .github/workflows/docker-amd64.yml | 16 ++++++++++++++-- .github/workflows/docker-arm64v8.yml | 18 +++++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml index 4836f95a..b18a62a5 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 eb857b47..d47c33a4 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 Docker connection and credentials shell: bash @@ -66,7 +65,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' @@ -104,6 +103,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 @@ -111,6 +113,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" From 9b39c5fda26df19ded2a30e7c6a04e5c7847a46d 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 20/31] [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 ac85508ffa8641f333b53038c65dcbcf46f17061 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Wed, 23 Sep 2026 13:41:14 +0900 Subject: [PATCH 21/31] fix: make ROS bootstrap downloads deterministic in Docker builds --- .docker/lyrical.arm64v8.dockerfile | 6 ++++-- extras/ros-lyrical-gz-jetty-install.sh | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.docker/lyrical.arm64v8.dockerfile b/.docker/lyrical.arm64v8.dockerfile index db482997..4170f14c 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/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 From ca60ba63dcdf60cb76d231255570106e43024862 Mon Sep 17 00:00:00 2001 From: woensug-choi Date: Tue, 29 Sep 2026 14:39:00 +0900 Subject: [PATCH 22/31] ci: reconcile PR runners with merged Node 24 updates --- .github/workflows/docker-amd64.yml | 2 +- .github/workflows/docker-arm64v8.yml | 19 ++++++++++--------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-amd64.yml b/.github/workflows/docker-amd64.yml index b18a62a5..e4cce253 100644 --- a/.github/workflows/docker-amd64.yml +++ b/.github/workflows/docker-amd64.yml @@ -71,7 +71,7 @@ jobs: - name: Upload PR image for trusted publishing if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: dave-pr-image-amd64 path: ${{ runner.temp }}/pr-image.tar diff --git a/.github/workflows/docker-arm64v8.yml b/.github/workflows/docker-arm64v8.yml index d47c33a4..e50e4963 100644 --- a/.github/workflows/docker-arm64v8.yml +++ b/.github/workflows/docker-arm64v8.yml @@ -35,21 +35,22 @@ jobs: set -euo pipefail export DOCKER_CONFIG="$RUNNER_TEMP/docker-config" - export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock" mkdir -p "$DOCKER_CONFIG" chmod 700 "$DOCKER_CONFIG" printf '{}\n' > "$DOCKER_CONFIG/config.json" chmod 600 "$DOCKER_CONFIG/config.json" - { - echo "DOCKER_CONFIG=$DOCKER_CONFIG" - echo "DOCKER_HOST=$DOCKER_HOST" - } >> "$GITHUB_ENV" + echo "DOCKER_CONFIG=$DOCKER_CONFIG" >> "$GITHUB_ENV" - if [ ! -S "$HOME/.docker/run/docker.sock" ]; then - echo "::error::Docker Desktop socket not found. Start Docker Desktop as the runner user." - exit 1 + # Hosted Linux PR runners use their existing Docker connection. + if [ "$RUNNER_OS" = "macOS" ]; then + export DOCKER_HOST="unix://$HOME/.docker/run/docker.sock" + echo "DOCKER_HOST=$DOCKER_HOST" >> "$GITHUB_ENV" + if [ ! -S "$HOME/.docker/run/docker.sock" ]; then + echo "::error::Docker Desktop socket not found. Start Docker Desktop as the runner user." + exit 1 + fi fi docker version @@ -115,7 +116,7 @@ jobs: - name: Upload PR image for trusted publishing if: github.event_name == 'pull_request' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7.0.1 with: name: dave-pr-image-arm64 path: ${{ runner.temp }}/pr-image.tar From 1cac96308e4fd4c45f4e4fd5f0e83b579986f2c3 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Sat, 10 Oct 2026 19:20:07 +0530 Subject: [PATCH 23/31] Rename sonar backend env var and CUDA define to POSIM Rename the runtime backend selector DAVE_SONAR_COMPUTE_BACKEND to POSIM_SONAR_COMPUTE_BACKEND in MultibeamSonarSensor.cc and the demo launch file, and the compile definition DAVE_HAS_CUDA_BACKEND to POSIM_HAS_CUDA_BACKEND. Update the wgpu_vendor package description. No compatibility alias is kept for the old variable. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: clean colcon build with and without CUDA, wgpu and cuda smoke runs, 15-minute wgpu soak (no memory growth), and range-axis and single-scene numerics matching the DAVE reference data. Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/CMakeLists.txt | 2 +- .../multibeam_sonar/MultibeamSonarSensor.cc | 6 +++--- .../multibeam_sonar/sonar_compute_cuda.cc | 8 ++++---- .../launch/multibeam_sonar_demo.launch.py | 2 +- gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml | 2 +- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt index 04358394..737c637d 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/CMakeLists.txt @@ -66,7 +66,7 @@ if(CUDAToolkit_FOUND) PROPERTIES CUDA_SEPARABLE_COMPILATION ON ) - target_compile_definitions(${PROJECT_NAME} PRIVATE DAVE_HAS_CUDA_BACKEND=1) + target_compile_definitions(${PROJECT_NAME} PRIVATE POSIM_HAS_CUDA_BACKEND=1) endif() target_include_directories(${PROJECT_NAME} PUBLIC diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index c7a6b901..eab98e7d 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -460,15 +460,15 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo sensorElement->Get("frameName", "forward_sonar_optical_link").first; gzmsg << "frameName: " << this->frameName << std::endl; - if (const char * backendEnv = std::getenv("DAVE_SONAR_COMPUTE_BACKEND")) + if (const char * backendEnv = std::getenv("POSIM_SONAR_COMPUTE_BACKEND")) { this->requestedBackend = backendEnv; - gzmsg << "DAVE_SONAR_COMPUTE_BACKEND=" << this->requestedBackend << std::endl; + gzmsg << "POSIM_SONAR_COMPUTE_BACKEND=" << this->requestedBackend << std::endl; } else { this->requestedBackend = "auto"; - gzmsg << "DAVE_SONAR_COMPUTE_BACKEND not set. Using default backend=auto" << std::endl; + gzmsg << "POSIM_SONAR_COMPUTE_BACKEND not set. Using default backend=auto" << std::endl; } this->computeBackend = CreateComputeBackend(this->requestedBackend); 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 3d3b2181..858d6e74 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 @@ -3,7 +3,7 @@ #include #include -#ifdef DAVE_HAS_CUDA_BACKEND +#ifdef POSIM_HAS_CUDA_BACKEND #include #include "sonar_calculation_cuda.cuh" #endif @@ -15,7 +15,7 @@ namespace sensors CudaComputeBackend::~CudaComputeBackend() { -#ifdef DAVE_HAS_CUDA_BACKEND +#ifdef POSIM_HAS_CUDA_BACKEND if (this->initialized) { NpsGazeboSonar::free_cuda_memory(); @@ -27,7 +27,7 @@ const char * CudaComputeBackend::Name() const { return "cuda"; } bool CudaComputeBackend::Initialize(const SonarComputeInput &) { -#ifdef DAVE_HAS_CUDA_BACKEND +#ifdef POSIM_HAS_CUDA_BACKEND int deviceCount = 0; const cudaError_t err = cudaGetDeviceCount(&deviceCount); if (err != cudaSuccess || deviceCount <= 0) @@ -44,7 +44,7 @@ bool CudaComputeBackend::Initialize(const SonarComputeInput &) bool CudaComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOutput & output) { -#ifdef DAVE_HAS_CUDA_BACKEND +#ifdef POSIM_HAS_CUDA_BACKEND if (!this->initialized) { return false; 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 c4bf519d..4d61c7be 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 @@ -65,7 +65,7 @@ def generate_launch_description(): description="Sonar backend selection: auto|wgpu|cuda|cpu", ), DeclareLaunchArgument("rviz", default_value="true", description="Open RViz."), - SetEnvironmentVariable("DAVE_SONAR_COMPUTE_BACKEND", compute_backend), + SetEnvironmentVariable("POSIM_SONAR_COMPUTE_BACKEND", compute_backend), multibeam_sonar_sim, rviz, ] diff --git a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml index 691fc516..fd833a36 100644 --- a/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml +++ b/gazebo/dave_gz_multibeam_sonar/wgpu_vendor/package.xml @@ -2,7 +2,7 @@ wgpu_vendor 0.1.0 - Vendor package exposing wgpu CMake target for DAVE sonar backend. + Vendor package exposing wgpu CMake target for the POSIM sonar backend. DAVE Maintainers Apache-2.0 ament_cmake From 996b4258d54a362f00972f013db0592fce55751e Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Sat, 10 Oct 2026 23:48:38 +0530 Subject: [PATCH 24/31] Keep the server alive when the sonar backend fails to initialize Select the compute backend before creating the GpuRays sensor. If sensor setup fails, destroy the GpuRays and reset the backend, and return no rendering sensors. MultibeamSonarSystem now removes a sensor that failed to initialize instead of attaching a null rendering sensor. Previously a missing or unusable backend (e.g. cuda with no visible device) left an unconfigured GpuRays in the scene and Ogre aborted the whole server on the next render. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: cuda with CUDA_VISIBLE_DEVICES='' keeps the server running and logs a single error; wgpu, cuda and cpu still publish point clouds. Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/MultibeamSonarSensor.cc | 55 ++++++++++++------- .../MultibeamSonarSystem.cc | 13 +++++ 2 files changed, 48 insertions(+), 20 deletions(-) diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc index eab98e7d..dd63cf1a 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/MultibeamSonarSensor.cc @@ -329,6 +329,15 @@ bool MultibeamSonarSensor::Implementation::Initialize(MultibeamSonarSensor * _se { gzerr << "Failed to initialize beam arrangement for " << "[" << _sensor->Name() << "] sensor." << std::endl; + // An unconfigured GpuRays left in the scene makes Ogre throw on the next render + // and aborts the server, so remove it and fail only this sensor. + this->rayConnection.reset(); + if (this->raySensor) + { + _sensor->Scene()->DestroySensor(this->raySensor); + this->raySensor.reset(); + } + this->computeBackend.reset(); return false; } @@ -366,6 +375,28 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo this->beams.clear(); this->beamTargets.clear(); + // Select the compute backend before adding anything to the scene, so an unavailable + // backend fails this sensor without leaving a half-configured GpuRays behind. + if (const char * backendEnv = std::getenv("POSIM_SONAR_COMPUTE_BACKEND")) + { + this->requestedBackend = backendEnv; + gzmsg << "POSIM_SONAR_COMPUTE_BACKEND=" << this->requestedBackend << std::endl; + } + else + { + this->requestedBackend = "auto"; + gzmsg << "POSIM_SONAR_COMPUTE_BACKEND not set. Using default backend=auto" << std::endl; + } + + this->computeBackend = CreateComputeBackend(this->requestedBackend); + if (!this->computeBackend) + { + gzerr << "Unable to initialize requested sonar backend [" << this->requestedBackend << "]." + << std::endl; + return false; + } + gzmsg << "Using sonar compute backend: " << this->computeBackend->Name() << std::endl; + this->raySensor = _sensor->Scene()->CreateGpuRays(_sensor->Name() + "_ray_sensor"); if (!this->raySensor) { @@ -460,26 +491,6 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo sensorElement->Get("frameName", "forward_sonar_optical_link").first; gzmsg << "frameName: " << this->frameName << std::endl; - if (const char * backendEnv = std::getenv("POSIM_SONAR_COMPUTE_BACKEND")) - { - this->requestedBackend = backendEnv; - gzmsg << "POSIM_SONAR_COMPUTE_BACKEND=" << this->requestedBackend << std::endl; - } - else - { - this->requestedBackend = "auto"; - gzmsg << "POSIM_SONAR_COMPUTE_BACKEND not set. Using default backend=auto" << std::endl; - } - - this->computeBackend = CreateComputeBackend(this->requestedBackend); - if (!this->computeBackend) - { - gzerr << "Unable to initialize requested sonar backend [" << this->requestedBackend << "]." - << std::endl; - return false; - } - gzmsg << "Using sonar compute backend: " << this->computeBackend->Name() << std::endl; - // ROS Initialization if (!rclcpp::ok()) @@ -811,6 +822,10 @@ bool MultibeamSonarSensor::Implementation::InitializeBeamArrangement(MultibeamSo ////////////////////////////////////////////////// std::vector MultibeamSonarSensor::RenderingSensors() const { + if (!this->dataPtr->raySensor) + { + return {}; + } return {this->dataPtr->raySensor}; } diff --git a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/MultibeamSonarSystem.cc b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/MultibeamSonarSystem.cc index 81c91798..dc4dd846 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/MultibeamSonarSystem.cc +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar_system/MultibeamSonarSystem.cc @@ -467,6 +467,19 @@ void MultibeamSonarSystem::Implementation::Handle(requests::CreateSensor _reques sensor->SetScene(this->scene); sensor->SetManualSceneUpdate(true); + // SetScene() initializes the sensor; on failure it has no rendering sensor to attach. + if (sensor->RenderingSensors().empty()) + { + gzerr << "Sensor [" << _request.sdf.Name() << "] failed to initialize and was " + << "removed. Other sensors and the simulation keep running." << std::endl; + if (!this->sensorManager.Remove(sensor->Id())) + { + gzerr << "Internal error, missing sensor " + << "[" << _request.sdf.Name() << "]" << std::endl; + } + return; + } + if (this->latestWorldState) { sensor->SetWorldState(*this->latestWorldState); From 02f61d9d1752715d8fef36cae9227692238a5890 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Sun, 11 Oct 2026 00:32:28 +0530 Subject: [PATCH 25/31] Report WGPU initialization failure instead of falling back to CPU WgpuComputeBackend::Initialize() returned true even when the adapter probe failed, then silently ran every frame on an internal CPU backend while the logs reported the WGPU backend. The CPU backend uses a different model, so the output was wrong as well as slow, and auto could never reach its own CPU branch. Initialize() now returns false when no adapter is usable, letting the factory fail an explicit wgpu request or move auto on to CPU. The internal CPU fallback is removed; a failed GPU frame makes Compute() return false, as the CUDA backend does. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: with no Vulkan ICD, wgpu fails cleanly with the server running and auto selects and reports CPU; wgpu, cuda and cpu still publish point clouds with a working GPU. Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/sonar_compute_wgpu.cc | 52 +++---------------- .../multibeam_sonar/sonar_compute_wgpu.hh | 2 - 2 files changed, 6 insertions(+), 48 deletions(-) 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 4fec4ada..75bf0f04 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 @@ -29,8 +29,8 @@ 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(). + // Probe the Rust FFI to check if a GPU adapter is available. If it is not, report failure + // so the factory can pick another backend instead of silently computing on the CPU. std::vector depth(1, 0.0f); std::vector normal(3, 0.0f); std::vector reflectivity(1, 0.0f); @@ -42,17 +42,12 @@ bool WgpuComputeBackend::Initialize(const SonarComputeInput &) 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) + 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; + std::cerr << "[sonar_wgpu] GPU probe failed: no usable WGPU adapter." << std::endl; + return false; } + sonar_wgpu_free(probe, 1u * 4u * 2u); this->initialized = true; return true; @@ -65,26 +60,6 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu return false; } - // If GPU is unavailable, go directly to CPU fallback - if (!this->gpuAvailable) - { - 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) - { - this->cpuFallback->Initialize(input); - } - } - if (this->cpuFallback) - { - return this->cpuFallback->Compute(input, output); - } - return false; - } - if (!input.depthImage || !input.normalImage || !input.reflectivityImage) { return false; @@ -176,21 +151,6 @@ bool WgpuComputeBackend::Compute(const SonarComputeInput & input, SonarComputeOu 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; } 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 index deb79c89..8929a6db 100644 --- a/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh +++ b/gazebo/dave_gz_multibeam_sonar/multibeam_sonar/sonar_compute_wgpu.hh @@ -20,8 +20,6 @@ public: private: bool initialized{false}; - bool gpuAvailable{false}; - std::unique_ptr cpuFallback; }; } // namespace sensors From 8e6501e2007f5f81507e953438a57e50c64676fe Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Sun, 11 Oct 2026 00:46:11 +0530 Subject: [PATCH 26/31] Reject unknown sonar compute backend names CreateComputeBackend() fell through to the CPU backend for any value it did not recognise, so a typo in POSIM_SONAR_COMPUTE_BACKEND or compute_backend silently produced CPU output. Unknown values now log the valid choices and fail the sensor, leaving the server running. An empty value is treated as auto, and auto's CPU fallback is an explicit branch. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: bogus fails cleanly, an empty value selects auto, and wgpu, cuda and cpu still publish point clouds. Signed-off-by: Naitik Pahwa --- .../multibeam_sonar/sonar_compute_cpu.cc | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 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 92acdf0a..b010268b 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 @@ -156,6 +156,12 @@ std::unique_ptr CreateComputeBackend(const std::string & request backend.begin(), backend.end(), backend.begin(), [](unsigned char c) { return static_cast(std::tolower(c)); }); + // An empty value behaves like an unset variable. + if (backend.empty()) + { + backend = "auto"; + } + if (backend == "wgpu") { std::cerr << "[sonar_compute_factory] Attempting WgpuComputeBackend initialization..." @@ -215,10 +221,13 @@ std::unique_ptr CreateComputeBackend(const std::string & request std::cerr << "[sonar_compute_factory] SUCCESS: Auto-selected CPU backend (both CUDA and WGPU " "unavailable)" << std::endl; + return std::make_unique(); } - std::cerr << "[sonar_compute_factory] Creating default CPU backend" << std::endl; - return std::make_unique(); + // Reject unknown values rather than silently substituting a different backend. + std::cerr << "[sonar_compute_factory] Unknown sonar compute backend '" << requestedBackend + << "'. Valid values: auto, wgpu, cuda, cpu." << std::endl; + return nullptr; } } // namespace sensors From cf94d24fab8e425c51d57544ff06648d2c3c9491 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Sun, 11 Oct 2026 01:26:06 +0530 Subject: [PATCH 27/31] Mark WGPU FFI functions unsafe and fix Rust lint sonar_wgpu_compute and sonar_wgpu_free dereference caller-supplied raw pointers, so declare them unsafe extern "C" and document their safety requirements. The exported C ABI is unchanged. Also apply clippy's div_ceil and is_none_or suggestions, turn the file header into a module doc comment, and run cargo fmt. cargo clippy with -D warnings and cargo fmt --check now pass. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: WGPU output is bit-identical to the previous build, and a missing Vulkan adapter still fails the backend cleanly. Signed-off-by: Naitik Pahwa --- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 196 +++++++++++++----- 1 file changed, 145 insertions(+), 51 deletions(-) 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 f8a7c170..da2e1867 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,7 +1,7 @@ -/// C-FFI interface, Buffer initialization and write ups and reads + Bind groups created + encoder setup + memory free -/// + Compute passes for each shader +//! C-FFI interface, Buffer initialization and write ups and reads + Bind groups created + encoder setup + memory free +//! + Compute passes for each shader -mod pipeline; // GPU context + buffer management +mod pipeline; // GPU context + buffer management use bytemuck::{Pod, Zeroable}; use std::fs::OpenOptions; @@ -24,8 +24,8 @@ struct BackscatterParams { 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 + 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, @@ -45,10 +45,10 @@ struct MatmulParams { #[repr(C)] #[derive(Clone, Copy, Pod, Zeroable)] struct FftParams { - 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) + 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) } fn emit_debug_timing(line: &str) { @@ -65,8 +65,16 @@ fn emit_debug_timing(line: &str) { } /// The function receives flat arrays from C++ and returns a heap-allocated array. +/// +/// # Safety +/// +/// Each input pointer must be null or valid for reads of its full length: +/// `depth_flat` and `reflectivity_flat` `n_beams * n_rays` floats, `normal_flat` +/// `n_beams * n_rays * 3`, `beam_corrector_flat` `n_beams * n_beams` and `window_flat` `n_freq`. +/// A non-null result holds `n_beams * n_freq * 2` floats and must be released with +/// [`sonar_wgpu_free`]. #[no_mangle] -pub extern "C" fn sonar_wgpu_compute( +pub unsafe extern "C" fn sonar_wgpu_compute( depth_flat: *const f32, normal_flat: *const f32, reflectivity_flat: *const f32, @@ -128,7 +136,11 @@ pub extern "C" fn sonar_wgpu_compute( } None => { static FALLBACK_LOG: Once = Once::new(); - FALLBACK_LOG.call_once(|| eprintln!("[sonar_wgpu] GPU init failed -> returning null (C++ will use CPU backend).")); + FALLBACK_LOG.call_once(|| { + eprintln!( + "[sonar_wgpu] GPU init failed -> returning null (C++ will use CPU backend)." + ) + }); return std::ptr::null_mut(); } }; @@ -169,7 +181,11 @@ pub extern "C" fn sonar_wgpu_compute( 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_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), @@ -180,23 +196,25 @@ pub extern "C" fn sonar_wgpu_compute( // ---- 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 - }); + let needs_alloc = buf_guard + .as_ref() + .is_none_or(|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); + 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.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.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)); + 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)); @@ -207,43 +225,100 @@ pub extern "C" fn sonar_wgpu_compute( 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() }, + 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() }, + 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() }, + 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() }, + 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(), + }, ], }); @@ -264,10 +339,10 @@ pub extern "C" fn sonar_wgpu_compute( }); pass.set_pipeline(&ctx.bs_pipeline); pass.set_bind_group(0, &bs_bg, &[]); - let gx = (n_beams + 7) / 8; + let gx = n_beams.div_ceil(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; + let reduced_rays = n_rays.div_ceil(ray_step); + let gy = reduced_rays.div_ceil(8); pass.dispatch_workgroups(gx, gy, 1); } @@ -279,14 +354,14 @@ pub extern "C" fn sonar_wgpu_compute( }); pass.set_pipeline(&ctx.convert_pipeline); pass.set_bind_group(0, &conv_bg, &[]); - let gx = (spectrum_len as u32 + 63) / 64; + let gx = (spectrum_len as u32).div_ceil(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 gx = n_freq.div_ceil(16); + let gy = n_beams.div_ceil(16); { let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("mm_re_pass"), @@ -327,9 +402,18 @@ pub extern "C" fn sonar_wgpu_compute( 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() }, + 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(), + }, ], }); { @@ -417,10 +501,14 @@ pub extern "C" fn sonar_wgpu_compute( // 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); + let active_rays = n_rays.div_ceil(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 + gpu_frame + 1, + elapsed_ms, + n_beams, + active_rays, + n_freq ); } @@ -430,8 +518,14 @@ pub extern "C" fn sonar_wgpu_compute( ptr } +/// Frees an array returned by [`sonar_wgpu_compute`]. +/// +/// # Safety +/// +/// `ptr` must be null or a pointer returned by `sonar_wgpu_compute` that has not been freed yet, +/// and `len` must be the length of that array (`n_beams * n_freq * 2`). #[no_mangle] -pub extern "C" fn sonar_wgpu_free(ptr: *mut f32, len: usize) { +pub unsafe extern "C" fn sonar_wgpu_free(ptr: *mut f32, len: usize) { if ptr.is_null() || len == 0 { return; } From 0fc8bb2dcadf55b0113e399c752b5331f3c5c288 Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Tue, 13 Oct 2026 03:59:39 +0530 Subject: [PATCH 28/31] Keep one WGPU buffer set per sonar dimensions The shared GPU context held a single set of persistent buffers. With two sonars of different dimensions in one world, every frame from the other sonar freed and reallocated the whole set (about 1000 times in a 30 s run). Keep a buffer set per (beams, rays, freq) instead, with a cap of 8 sets. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060: - one sonar: WGPU and CUDA output byte-identical to the previous build (80/80 frames each) - two sonars (512x300 + 256x600): WGPU output byte-identical (9/9 run pairs); reallocations drop from about 1000 to 3 per run. Per-frame time and throughput are unchanged within run-to-run noise. Signed-off-by: Naitik Pahwa --- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 25 +++++++++++-------- .../sonar_wgpu_rust/src/pipeline.rs | 25 ++++++++++--------- 2 files changed, 27 insertions(+), 23 deletions(-) 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 da2e1867..5d4b8b06 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 @@ -194,20 +194,23 @@ pub unsafe extern "C" fn sonar_wgpu_compute( // REMOVE THIS, UNNCESSARY // ---- Persistent data buffers: allocate once per dimension set, reuse every frame ---- + // The lock is held for the whole frame, so sonars with equal dimensions can share a set. let mut buf_guard = ctx.buffers.lock().unwrap(); - { - let needs_alloc = buf_guard - .as_ref() - .is_none_or(|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 key = (n_beams, n_rays, n_freq); + if !buf_guard.contains_key(&key) { + if buf_guard.len() >= pipeline::MAX_BUFFER_SETS { + buf_guard.clear(); } + buf_guard.insert( + key, + 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(); + let buf = &buf_guard[&key]; // Upload frame data into persistent buffers (DMA, no GPU alloc) queue.write_buffer(&buf.depth_buf, 0, bytemuck::cast_slice(depth)); 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 d208d617..41267295 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,13 +1,17 @@ /// Adapters, device, queue setups + BUFFERS SETUP + PIPELINE SETUPS + PIPELINE COMPILE + GPU CONTEXT INIT all in pipeline.rs /// + Shaders compiled +use std::collections::HashMap; use std::sync::OnceLock; +/// Buffer-set key: (n_beams, n_rays, n_freq). +pub type BufferKey = (u32, u32, u32); + +/// Upper bound on cached buffer sets; the cache is cleared when it is full. +pub const MAX_BUFFER_SETS: usize = 8; + /// GPU buffer lifetime: persistent allocation across frames via queue.write_buffer. -/// Buffers reallocated only when sonar dimensions change. +/// One set per sonar dimensions (see `GpuContext::buffers`). 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, @@ -42,9 +46,6 @@ impl SonarBuffers { 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), @@ -165,10 +166,10 @@ pub struct GpuContext { 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>, + // Persistent GPU buffers across frames, one set per (n_beams, n_rays, n_freq), allocated on the + // first compute call with those dimensions. Keying by dimensions lets several sonars of + // different sizes share the context without reallocating every frame. + pub buffers: std::sync::Mutex>, } static GPU_CONTEXT: OnceLock> = OnceLock::new(); @@ -382,7 +383,7 @@ fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option Date: Tue, 13 Oct 2026 05:08:26 +0530 Subject: [PATCH 29/31] Honour WGPU_BACKEND, WGPU_ADAPTER_NAME and WGPU_POWER_PREF The WGPU backend hard-coded its backend list and the high-performance adapter, so the standard wgpu environment variables had no effect and the GPU could only be chosen through the Vulkan loader. On Linux the fallback "all" attempt also tried OpenGL, whose EGL setup always panics inside Gazebo. - WGPU_BACKEND, if set, replaces the default backend list. - WGPU_ADAPTER_NAME picks the first adapter whose name contains it (case-insensitive). If none matches, the available adapters are logged and initialization fails cleanly. - WGPU_POWER_PREF is honoured; the default stays high-performance. - Linux now tries Vulkan only. Windows and macOS are unchanged. - Log messages no longer mention a CPU fallback that no longer exists. Tested on Ubuntu 26.04.1 / ROS 2 Lyrical / gz-sim 10.5.0, RTX 4060 + Intel RPL-S iGPU: - default settings: WGPU and CUDA output byte-identical to the previous build (80/80 frames each); per-frame time unchanged - WGPU_ADAPTER_NAME=Intel runs on the iGPU; output matches the NVIDIA run to within 1.4e-4 of the peak, with the same range accuracy - bogus adapter name, WGPU_BACKEND=gl, or no Vulkan driver: the sensor is removed and the server keeps running - cargo fmt --check and cargo clippy -D warnings pass Signed-off-by: Naitik Pahwa --- .../wgpu_vendor/sonar_wgpu_rust/src/lib.rs | 6 +- .../sonar_wgpu_rust/src/pipeline.rs | 55 +++++++++++++++---- 2 files changed, 44 insertions(+), 17 deletions(-) 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 5d4b8b06..a8c6ea19 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 @@ -136,11 +136,7 @@ pub unsafe extern "C" fn sonar_wgpu_compute( } None => { static FALLBACK_LOG: Once = Once::new(); - FALLBACK_LOG.call_once(|| { - eprintln!( - "[sonar_wgpu] GPU init failed -> returning null (C++ will use CPU backend)." - ) - }); + FALLBACK_LOG.call_once(|| eprintln!("[sonar_wgpu] GPU init failed -> returning null.")); return std::ptr::null_mut(); } }; 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 41267295..a536927b 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 @@ -285,11 +285,10 @@ fn compile_pipeline( }) } +// Default backend sets, tried in order. WGPU_BACKEND replaces them with a single set. +// Linux tries Vulkan only: the GL backend's EGL setup panics inside Gazebo. #[cfg(target_os = "linux")] -const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ - (wgpu::Backends::VULKAN, "vulkan"), - (wgpu::Backends::all(), "all"), -]; +const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[(wgpu::Backends::VULKAN, "vulkan")]; #[cfg(target_os = "windows")] const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ @@ -306,6 +305,38 @@ const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[ #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] const BACKEND_ATTEMPTS: &[(wgpu::Backends, &str)] = &[(wgpu::Backends::all(), "all")]; +/// Pick an adapter. WGPU_ADAPTER_NAME selects the first adapter whose name contains it +/// (case-insensitive); otherwise wgpu chooses by WGPU_POWER_PREF (default: high performance). +fn select_adapter( + instance: &wgpu::Instance, + backends: wgpu::Backends, + label: &str, +) -> Option { + if let Ok(wanted) = std::env::var("WGPU_ADAPTER_NAME") { + let wanted = wanted.to_lowercase(); + let adapters = instance.enumerate_adapters(backends); + let names: Vec = adapters.iter().map(|a| a.get_info().name).collect(); + let found = adapters + .into_iter() + .find(|a| a.get_info().name.to_lowercase().contains(&wanted)); + if found.is_none() { + eprintln!( + "[sonar_wgpu] [{label}] no adapter matches WGPU_ADAPTER_NAME='{wanted}'; available: {names:?}" + ); + } + return found; + } + + pollster::block_on( + instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::util::power_preference_from_env() + .unwrap_or(wgpu::PowerPreference::HighPerformance), + compatible_surface: None, + force_fallback_adapter: false, + }), + ) +} + fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option { eprintln!("[sonar_wgpu] Trying backend set: {label}"); @@ -316,11 +347,7 @@ fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option Option ctx, Err(_) => { - eprintln!("[sonar_wgpu] [{label}] GPU init panicked; trying next backend set"); + eprintln!("[sonar_wgpu] [{label}] GPU init panicked"); None } } @@ -398,18 +425,22 @@ fn try_init_gpu_context(backends: wgpu::Backends, label: &str) -> Option Option { + if let Some(backends) = wgpu::util::backend_bits_from_env() { + eprintln!("[sonar_wgpu] WGPU_BACKEND set; using only {backends:?}"); + return try_init_gpu_context(backends, "WGPU_BACKEND"); + } 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"); + eprintln!("[sonar_wgpu] GPU init failed for all backend sets"); None } /// Global singleton: lazily initializes GPU context on first access. -/// Returns None if GPU unavailable; falls back to CPU implementation. +/// Returns None if no usable GPU adapter was found. pub fn get_or_init() -> Option<&'static GpuContext> { GPU_CONTEXT.get_or_init(init_gpu_context).as_ref() } From ad6165bd6c2b4e407f18ecb264e20bb76da8d4ca Mon Sep 17 00:00:00 2001 From: Naitik Pahwa Date: Tue, 13 Oct 2026 07:13:42 +0530 Subject: [PATCH 30/31] Correct auto backend order in demo guides Signed-off-by: Naitik Pahwa --- gazebo/DEMO_GUIDE.md | 2 +- gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/gazebo/DEMO_GUIDE.md b/gazebo/DEMO_GUIDE.md index 60426ab5..62156e65 100644 --- a/gazebo/DEMO_GUIDE.md +++ b/gazebo/DEMO_GUIDE.md @@ -42,7 +42,7 @@ On subsequent builds, you can run all packages in one command. ros2 launch posim_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). +Use `compute_backend:=cuda` for CUDA, `compute_backend:=cpu` for CPU, or `compute_backend:=auto` to pick the best available (tries cuda -> wgpu -> 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. diff --git a/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md index 01622480..524f3eee 100644 --- a/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md +++ b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md @@ -47,7 +47,7 @@ On subsequent builds, you can run all packages in one command. ros2 launch posim_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). +Use `compute_backend:=cuda` for CUDA, `compute_backend:=cpu` for CPU, or `compute_backend:=auto` to pick the best available (tries cuda -> wgpu -> 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. From a1786726bd77290f7fba40d8302dec385e6a5324 Mon Sep 17 00:00:00 2001 From: ioes Date: Tue, 13 Oct 2026 14:01:58 +0900 Subject: [PATCH 31/31] Document sonar backends and demos for public users --- README.md | 8 ++ .../engineering/sonar-public-documentation.md | 40 ++++++++ gazebo/DEMO_GUIDE.md | 89 ++--------------- gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md | 93 ++--------------- gazebo/posim_gz_multibeam_sonar/README.md | 31 +++--- website/catalog.json | 2 +- website/content/en/index.md | 2 +- website/content/en/install.md | 5 +- website/content/en/requirements.md | 2 +- website/content/en/sonar-tuning.md | 97 +++++++++++++++--- website/content/en/sonar.md | 50 ++++++++-- website/content/en/troubleshooting.md | 2 +- website/content/ko/index.md | 2 +- website/content/ko/install.md | 5 +- website/content/ko/requirements.md | 2 +- website/content/ko/sonar-tuning.md | 99 ++++++++++++++++--- website/content/ko/sonar.md | 54 +++++++--- website/content/ko/troubleshooting.md | 2 +- website/sources.json | 2 +- 19 files changed, 354 insertions(+), 233 deletions(-) create mode 100644 docs/engineering/sonar-public-documentation.md diff --git a/README.md b/README.md index 9c3c17c1..04cebfe2 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,14 @@ Start with [installation](https://ioes-lab.github.io/POSIM/install.html) or [Docker](https://ioes-lab.github.io/POSIM/docker.html), then run your [first simulation](https://ioes-lab.github.io/POSIM/quickstart.html). +## Multibeam sonar + +Run the BlueView P900 sensor with WGPU, CUDA or CPU processing. Select the backend +at launch and inspect sonar images, raw acoustic data and point clouds. + +- English: [sensor and demo](website/content/en/sonar.md), [build and backend setup](website/content/en/sonar-tuning.md), [Apple Silicon](website/content/en/sonar-tuning.md#apple-silicon) +- 한국어: WGPU·CUDA·CPU 계산 방식을 선택하여 소나 영상·원시 음향 데이터·점군을 확인합니다. [센서와 예제](website/content/ko/sonar.md), [빌드와 계산 설정](website/content/ko/sonar-tuning.md), [Apple Silicon](website/content/ko/sonar-tuning.md#apple-silicon) + ## Try WWW-POSIM [WWW-POSIM](https://www-posim.vercel.app/) uses POSIM as its simulation engine. diff --git a/docs/engineering/sonar-public-documentation.md b/docs/engineering/sonar-public-documentation.md new file mode 100644 index 00000000..93a2bf7a --- /dev/null +++ b/docs/engineering/sonar-public-documentation.md @@ -0,0 +1,40 @@ +# Sonar public documentation evidence + +## Source snapshot + +- POSIM sonar PR: https://github.com/IOES-Lab/POSIM/pull/6 +- Source head: `ad6165bd6c2b4e407f18ecb264e20bb76da8d4ca`. +- Input guides: `gazebo/DEMO_GUIDE.md` and `gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md`. +- Runtime report: https://github.com/IOES-Lab/POSIM/pull/6#issuecomment-6135206430. +- User clarified that these documentation changes belong in PR #6; PR #7 is the closed ocean-waves contribution. + +## Source-backed user behavior + +- `sonar_compute_cpu.cc`: `auto` tries CUDA, WGPU, CPU; explicit failures and invalid names return no backend. +- `MultibeamSonarSensor.cc`: environment name is `POSIM_SONAR_COMPUTE_BACKEND`; publishes Image and ProjectedSonarImage directly to ROS 2, and PointCloudPacked over Gazebo transport; rendering is required for GpuRays. +- Demo launch: declares `compute_backend` (default `auto`) and `rviz`, and sets the POSIM backend environment variable. +- Sonar CMake: WGPU is required, CUDA is optional, `CUDA_ARCHITECTURE` defaults to `native` and forwards to `CMAKE_CUDA_ARCHITECTURES`; Apple builds link Metal/QuartzCore. +- Rust pipeline: Vulkan is the Linux default, Metal is the macOS default; supports adapter substring, backend API and power-preference selection; GPU context is shared in-process. +- WGPU 0.20.1 power-preference values checked against https://github.com/gfx-rs/wgpu/blob/v0.20.1/wgpu/src/util/init.rs: `high`, `low`, `none`. + +## Documentation decisions + +Public pages describe present behavior and commands. The two demo files and sonar README point to the authoritative English/Korean guides. Root README and installation, requirements, overview and troubleshooting pages use the same supported backend facts. + +The inherited demo guides refer to Ubuntu 24.04/ROS Rolling and macOS Jazzy/Harmonic setup. These version-specific setup commands are not copied into Lyrical/Jetty instructions. Native macOS instructions state the matching ROS/Gazebo/rendering prerequisites; they are not a turnkey host installer or a fresh native runtime validation. + +The contributor reports Ubuntu 26.04.1, ROS 2 Lyrical, Gazebo 10.5.0, RTX 4060 and Intel iGPU runtime tests. Reported panel range errors at 2–8 m and 0–45 degrees were approximately 0.03–0.05 m for WGPU and 0.03–0.06 m for CUDA, with 2.5 cm range bins. These are attributed test observations, not new measurements by this documentation change or public accuracy guarantees. + +The public text omits migration history, commit counts, implementation cleanup, shared-buffer allocation counts and before/after comparisons. Build/check and browser review results are recorded separately from user instructions. + +## Documentation validation + +- Node.js 24: `pnpm catalog`, `pnpm build`, `pnpm check`. +- Full repository check: 50 pages, 3,575 local links/anchors, 38 bilingual public media, bilingual search and 42 source launch references. +- Website-only build/check passes without the ROS/model source tree. +- EN/KO command blocks match for sonar, sonar tuning, installation, requirements, overview and troubleshooting. Sonar Bash blocks pass `bash -n`. +- Both demo wrappers pass the public-wording checker. `git diff --check` passes. +- WebKit desktop (1440 x 1000) and mobile (390 x 844) checks cover both sonar pages in both languages: no document overflow or JavaScript page errors, and language switching passes. +- Desktop Apple Silicon section and Korean mobile demo output visually inspected. Browser captures/results are local ignored files under `website/.validation/sonar/`. +- Website documentation-basis metadata uses the inspected PR source snapshot; no Notion review date or source media metadata is changed. +- These are documentation checks. This change does not claim a new sonar simulation run or a native Apple Silicon build. diff --git a/gazebo/DEMO_GUIDE.md b/gazebo/DEMO_GUIDE.md index 62156e65..f72c1682 100644 --- a/gazebo/DEMO_GUIDE.md +++ b/gazebo/DEMO_GUIDE.md @@ -1,83 +1,12 @@ -# Multibeam Sonar Demo Guide +# Multibeam sonar demo -Build and run the GPU multibeam sonar demo. The plugin supports wgpu (Vulkan), CUDA (NVIDIA), and CPU backends—pick one at launch without recompiling. +Run the BlueView P900 demo with WGPU, CUDA or CPU processing. The public guides +cover build prerequisites, launch commands, backend selection, output topics +and troubleshooting. -## Setup +- English: [run the demo](../website/content/en/sonar.md), [build and backend setup](../website/content/en/sonar-tuning.md) +- 한국어: [소나 예제 실행](../website/content/ko/sonar.md), [빌드와 계산 설정](../website/content/ko/sonar-tuning.md) -**Prerequisites:** -- Ubuntu 24.04 -- ROS 2 Rolling -- Gazebo Jetty (gz-sim 10) -- Rust/Cargo -- Vulkan driver -- CUDA 12+ (if using the CUDA backend) - -Clone the repository: -```bash -git clone https://github.com/IOES-Lab/POSIM.git -cd POSIM -``` - -## 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 posim_demos posim_worlds posim_interfaces multibeam_sonar multibeam_sonar_system posim_multibeam_sonar_demo posim_sensor_models -source install/setup.bash -``` - -On subsequent builds, you can run all packages in one command. - -## Run - -```bash -ros2 launch posim_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 cuda -> wgpu -> 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 +POSIM uses ROS 2 Lyrical and Gazebo Jetty. Start with the +[installation guide](../website/content/en/install.md) or +[한국어 설치 안내](../website/content/ko/install.md), then follow the sonar guides above. diff --git a/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md index 524f3eee..4b342df4 100644 --- a/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md +++ b/gazebo/DEMO_GUIDE_AppleSilicon_MacOSX.md @@ -1,88 +1,11 @@ -# 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 on Apple Silicon -![Multibeam Sonar Demo](https://github.com/user-attachments/assets/8fbe8a3f-e917-42af-a51d-c49f133da882) +The public sonar build guide covers Metal for native macOS and CPU acoustic +processing in a Linux ARM64 container. Both require Gazebo rendering and a +matching ROS 2 / Gazebo environment. -## Setup +- English: [Apple Silicon setup](../website/content/en/sonar-tuning.md#apple-silicon), [sensor and demo](../website/content/en/sonar.md) +- 한국어: [Apple Silicon 설정](../website/content/ko/sonar-tuning.md#apple-silicon), [센서와 예제](../website/content/ko/sonar.md) -### 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 posim_demos posim_worlds posim_interfaces multibeam_sonar multibeam_sonar_system posim_multibeam_sonar_demo posim_sensor_models -source install/setup.bash -``` - -On subsequent builds, you can run all packages in one command. - -## Run - -```bash -ros2 launch posim_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 cuda -> wgpu -> 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 +Use the [Docker guide](../website/content/en/docker.md) or +[한국어 Docker 안내](../website/content/ko/docker.md) for a Linux ARM64 environment. diff --git a/gazebo/posim_gz_multibeam_sonar/README.md b/gazebo/posim_gz_multibeam_sonar/README.md index 1fabca55..ca7839ab 100644 --- a/gazebo/posim_gz_multibeam_sonar/README.md +++ b/gazebo/posim_gz_multibeam_sonar/README.md @@ -1,20 +1,25 @@ # POSIM multibeam sonar -The ray-based point-scattering model produces intensity/range data, with -phase, reverberation and speckle effects. +The ray-based point-scattering model produces intensity–range data, sonar images +and point clouds. Select WGPU, CUDA or CPU processing at launch. WGPU uses Vulkan +on Linux and Metal on native macOS; all backends require Gazebo rendering. -## Backend requirements +## Documentation -- ROS 2 Lyrical and Gazebo Jetty -- Compatible NVIDIA GPU and CUDA toolkit -- Built CUDA sonar libraries +- [Sensor, demo and output topics](../../website/content/en/sonar.md) +- [Build, backend selection and troubleshooting](../../website/content/en/sonar-tuning.md) +- [Apple Silicon](../../website/content/en/sonar-tuning.md#apple-silicon) +- [POSIM installation](../../website/content/en/install.md) +- [Research citation and licenses](../../website/content/en/citation.md) -CMake skips CUDA targets when the toolkit is unavailable. Check library -installation and sensor output with the [sonar build guide](../../website/content/en/sonar-tuning.md). +## 한국어 안내 -## Documentation +광선 기반 점 산란 모델로 강도·거리 데이터, 소나 영상과 점군을 생성합니다. +실행할 때 WGPU·CUDA·CPU 계산 방식을 선택합니다. WGPU는 Linux에서 Vulkan, +macOS에서 직접 실행할 때 Metal을 사용합니다. 모든 방식에서 Gazebo 렌더링이 필요합니다. -See the [POSIM sonar guide](../../website/content/en/sonar.md) -for the model and configuration. Use the -[POSIM installation guide](../../website/content/en/install.md) for current repository -and workspace setup. +- [센서·예제·출력 토픽](../../website/content/ko/sonar.md) +- [빌드·계산 방식·문제 해결](../../website/content/ko/sonar-tuning.md) +- [Apple Silicon](../../website/content/ko/sonar-tuning.md#apple-silicon) +- [POSIM 설치](../../website/content/ko/install.md) +- [연구 인용과 라이선스](../../website/content/ko/citation.md) diff --git a/website/catalog.json b/website/catalog.json index 34924ddc..c3ec48d0 100644 --- a/website/catalog.json +++ b/website/catalog.json @@ -1,5 +1,5 @@ { - "sourceRevision": "23bb608a9a89b9cda3472792316a81711f1ce73e", + "sourceRevision": "ad6165bd6c2b4e407f18ecb264e20bb76da8d4ca", "worlds": [ "camera_tutorial.world", "dvl_world.world", diff --git a/website/content/en/index.md b/website/content/en/index.md index d5dad934..ed432244 100644 --- a/website/content/en/index.md +++ b/website/content/en/index.md @@ -29,7 +29,7 @@ Prepare an [Ubuntu installation](install.md) or [Docker environment](docker.md), | --- | --- | --- | | Ocean worlds | Surface, seabed and task scenes | [World library](worlds.md) | | Robots | REXROV, BlueROV2, Slocum and surface vehicles | [ROVs](rovs.md), [Gliders](gliders.md), [Surface robots](surface.md) | -| Sensors | Camera, DVL, pressure, USBL and CUDA sonar | [Camera](camera.md), [DVL](dvl.md), [Sonar](sonar.md) | +| Sensors | Camera, DVL, pressure, USBL and multibeam sonar | [Camera](camera.md), [DVL](dvl.md), [Sonar](sonar.md) | | Environment plugins | Currents and geographic coordinates | [Currents](currents.md), [Coordinates](coordinates.md) | | ROS interfaces | Sensor subscriptions, commands and recording | [ROS 2 and control](ros.md) | diff --git a/website/content/en/install.md b/website/content/en/install.md index fb5681e6..e2880e72 100644 --- a/website/content/en/install.md +++ b/website/content/en/install.md @@ -47,6 +47,9 @@ rosdep install --rosdistro lyrical --from-paths src --ignore-src -r -y ## 4. Build and source ```bash +colcon build --merge-install --executor sequential --symlink-install \ + --packages-select wgpu_vendor +source install/setup.bash colcon build --merge-install --executor sequential --symlink-install source install/setup.bash ros2 pkg prefix posim_demos @@ -60,7 +63,7 @@ source "$HOME/.ros_ardusub_env/env" source ~/posim_ws/install/setup.bash ``` -Continue with [First simulation](quickstart.md). NVIDIA/CUDA sonar has additional [build requirements](sonar-tuning.md). +Continue with [First simulation](quickstart.md). Choose the sonar compute backend using [Sonar build and performance](sonar-tuning.md). If the compiler is killed because memory is exhausted, limit compiler jobs as well as package concurrency. `--executor sequential` serializes packages, not compiler processes: diff --git a/website/content/en/requirements.md b/website/content/en/requirements.md index 30f7a4df..9b66d7fe 100644 --- a/website/content/en/requirements.md +++ b/website/content/en/requirements.md @@ -21,7 +21,7 @@ Run the native recipe in a Bash shell on Ubuntu. Docker commands run on the host Camera, depth and DVL processing require a renderer even without a desktop window. Software rendering can provide an initial data check. -Multibeam sonar requires NVIDIA GPU access, CUDA and built sonar libraries. See [Sonar build and performance](sonar-tuning.md). +Multibeam sonar supports WGPU, CUDA and CPU processing. Build its Rust/Cargo library and sonar plugins, then prepare the selected backend and Gazebo rendering. See [Sonar build and performance](sonar-tuning.md). ## Plan resources for your experiment diff --git a/website/content/en/sonar-tuning.md b/website/content/en/sonar-tuning.md index e7a4b6c8..f7f08738 100644 --- a/website/content/en/sonar-tuning.md +++ b/website/content/en/sonar-tuning.md @@ -1,46 +1,121 @@ # Sonar build and performance -Multibeam sonar uses CUDA. Prepare the NVIDIA driver, CUDA compiler/runtime, cuFFT, cuBLAS and Gazebo rendering. +Build the multibeam sonar once, then select WGPU, CUDA or CPU when launching it. WGPU uses Vulkan on Linux and Metal on native macOS. All compute choices require Gazebo rendering for the ray sensor. -## Check the toolchain +## Prepare the toolchain -On the machine or inside the container that will run sonar: +Use the [POSIM workspace setup](install.md) with ROS 2 Lyrical, Gazebo Jetty and ROS–Gazebo bridge packages. Run the build from the workspace root, such as `~/posim_ws`. + +- Rust and Cargo are required to build the WGPU library, including when you plan to select CPU processing. +- Linux WGPU needs a Vulkan loader and an appropriate GPU driver. `vulkaninfo --summary` lists available adapters. +- CUDA needs a compatible NVIDIA GPU, its driver, the CUDA compiler/runtime, cuFFT and cuBLAS. CUDA support is included when CMake finds the toolkit. +- Rendering needs Ogre2 and a working display, virtual display or configured headless renderer. + +```bash +rustc --version +cargo --version +``` + +If Rust is unavailable, use the [official Rust installer](https://www.rust-lang.org/tools/install). For CUDA, also check: ```bash nvidia-smi nvcc --version ``` -GPU visibility and compiler availability are separate checks. For containers, configure [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) on the host as well as the required libraries inside the image. +GPU access and compiler availability are separate checks. Linux containers using NVIDIA GPUs also need the host's [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) and the driver/runtime libraries inside the container. + +## Build and source + +On a fresh Ubuntu workspace, build and source the WGPU library before building the remaining packages: + +```bash +source /opt/ros/lyrical/setup.bash +colcon build --merge-install --executor sequential --symlink-install \ + --packages-select wgpu_vendor +source install/setup.bash +colcon build --merge-install --executor sequential --symlink-install +source install/setup.bash +``` + +In an already sourced native macOS workspace, use the same two build commands with that workspace's ROS environment. Rebuild the workspace after changing its compiler or GPU toolchain. ## Select a CUDA architecture -The sonar CMake configuration exposes `CUDA_ARCHITECTURE`, defaulting to `60`. Set it to the SM version supported by your GPU and CUDA toolkit. From a sourced workspace, rebuild after choosing that value: +The sonar CMake variable `CUDA_ARCHITECTURE` defaults to `native`. It selects the local GPU's architecture when CUDA support is built. For a different deployment GPU, supply the SM value supported by that GPU and toolkit: ```bash colcon build --merge-install --executor sequential --symlink-install \ + --packages-select multibeam_sonar \ --cmake-args -DCUDA_ARCHITECTURE=YOUR_GPU_SM source install/setup.bash ``` -Replace `YOUR_GPU_SM` with the numeric architecture. Check CUDA's supported architectures before selecting it; a toolkit may have dropped an older target. Do not confuse this project-specific singular variable with CMake's `CMAKE_CUDA_ARCHITECTURES`. +Replace `YOUR_GPU_SM` with the numeric architecture. The project forwards this value to CMake's `CMAKE_CUDA_ARCHITECTURES`. Check the toolkit's supported targets before selecting a value. ## Verify installed targets ```bash +ros2 pkg prefix wgpu_vendor ros2 pkg prefix multibeam_sonar ros2 pkg prefix multibeam_sonar_system ros2 pkg prefix posim_multibeam_sonar_demo ``` -Check library files to confirm CUDA targets were built. Packages can register even when CUDA targets are skipped. +On Linux, check the installed libraries: ```bash +test -f "$(ros2 pkg prefix wgpu_vendor)/lib/libsonar_wgpu.a" test -f "$(ros2 pkg prefix multibeam_sonar)/lib/multibeam_sonar/libmultibeam_sonar.so" test -f "$(ros2 pkg prefix multibeam_sonar_system)/lib/multibeam_sonar_system/libmultibeam_sonar_system.so" ``` -Each command must exit with status 0. Check build/launch logs for skipped targets or load errors, then receive image/raw data in [Multibeam sonar](sonar.md). +Each command must exit with status 0. Run the [sonar example](sonar.md) and confirm the selected backend in its log, then receive image and raw data. A package prefix or library file alone does not confirm GPU access. + +## Choose a WGPU adapter + +Set these variables in the terminal that launches Gazebo: + +| Variable | Behavior | +| --- | --- | +| `WGPU_BACKEND` | Selects the GPU API, such as `vulkan` on Linux or `metal` on native macOS | +| `WGPU_ADAPTER_NAME` | Selects the first adapter whose name contains the value, ignoring case | +| `WGPU_POWER_PREF` | Requests `high`, `low` or `none`; the default is `high` | + +For example, select a Vulkan adapter whose name contains `RTX`: + +```bash +WGPU_BACKEND=vulkan WGPU_ADAPTER_NAME=RTX WGPU_POWER_PREF=high \ +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu +``` + +Use an adapter name available on your machine. The log lists available names when a requested name has no match. Once selected, the WGPU adapter is shared by the sonar sensors in that Gazebo process; start a new process to change it. + +## Apple Silicon + +For native macOS execution, prepare a sourced ROS 2 workspace with Gazebo Jetty, Ogre2 and the ROS–Gazebo bridge. Build Rust/Cargo and the sonar packages as above. The WGPU compute backend uses the Apple GPU through Metal: + +```bash +WGPU_BACKEND=metal \ +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu +``` + +Metal provides the acoustic computation; Gazebo still needs its rendering environment. Use `compute_backend:=cpu` to select CPU acoustic processing. CUDA requires a compatible NVIDIA environment. + +For a Linux ARM64 container on an Apple Silicon host, follow [Docker setup and virtual-display rendering](docker.md). Run the [server-only sonar command](sonar.md) with `POSIM_SONAR_COMPUTE_BACKEND=cpu` inside the container. A Linux container requires a Vulkan adapter for WGPU; its compute API is separate from native macOS Metal. + +## Resolve startup errors + +| Symptom | Action | +| --- | --- | +| `wgpu_vendorConfig.cmake` is missing | Build `wgpu_vendor`, source `install/setup.bash`, then build the remaining packages | +| WGPU reports no usable adapter | Check the GPU driver, `vulkaninfo --summary` on Linux and adapter-selection variables; choose `auto` or `cpu` if GPU processing is unavailable | +| Explicit CUDA selection fails | Check CUDA build support, `nvidia-smi`, the runtime libraries and container GPU access | +| An unknown backend is reported | Use `auto`, `wgpu`, `cuda` or `cpu` | +| Another world with the same name is running | Stop the existing POSIM launch with Ctrl+C before relaunching | +| Backend initializes but no image arrives | Confirm the world is unpaused, then check the renderer and the [sonar topics](sonar.md) | ## Tune one variable at a time @@ -53,12 +128,12 @@ Each command must exit with status 0. Check build/launch logs for skipped target | `writeLog`, `debugFlag` | Disk and console overhead | | Sensor gain | Image presentation; distinguish it from physical return strength | -Record frame processing time, received topic rate, Gazebo real-time factor, CPU/GPU utilization and memory with the same scene and pose. Separate physics, rendering, CUDA computation, publication and logging costs. Warm up the renderer before timing. +Record frame processing time, received topic rate, Gazebo real-time factor and CPU/GPU utilization in the same scene and pose. Separate physics, rendering, acoustic computation, publication and recording costs. Allow the renderer to warm up before measuring. The configured 30 Hz update rate is a request; received throughput depends on the scene, sensor settings and hardware. -Retain a baseline dataset when changing numerical kernels. Compare intensity/range outputs, not only rendered screenshots. Upstream research and attribution are listed under [Citation](citation.md). +Use raw intensity–range data when comparing backends or sensor settings, and keep gain and display settings fixed. Research sources and attribution are listed under [Citation](citation.md). ## GPU processing flow -Sonar processing includes ray summation, window/correction and FFT stages. Separate stage timings from GPU memory transfers when profiling. Figure: POSIM Notion Wiki. +Sonar processing includes backscatter, beam correction and range compression. Separate computation time from memory transfers when profiling. Figure: POSIM Notion Wiki. diff --git a/website/content/en/sonar.md b/website/content/en/sonar.md index 93c7ac57..5550c790 100644 --- a/website/content/en/sonar.md +++ b/website/content/en/sonar.md @@ -1,22 +1,41 @@ # Multibeam sonar -POSIM's multibeam sonar uses a ray-based point-scattering model to generate intensity–range data and sonar imagery. Prepare the [NVIDIA/CUDA backend](sonar-tuning.md) before running the sensor. +POSIM's multibeam sonar generates intensity–range data, sonar images and point clouds from a ray-based point-scattering model. Choose WGPU, CUDA or CPU processing at launch. Prepare the [sonar build and rendering environment](sonar-tuning.md) first. ## Run a sonar example +From a sourced POSIM workspace, launch the BlueView P900 demo with WGPU: + ```bash -ros2 launch posim_demos posim_sensor.launch.py \ - namespace:=blueview_p900 world_name:=posim_multibeam_sonar \ - paused:=false x:=4 z:=2.0 yaw:=3.14 gui:=false headless:=true +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu ``` -A dedicated example is also supplied by `posim_multibeam_sonar_demo`: +The demo opens Gazebo and RViz. RViz shows the sensor's point cloud. Use `rviz:=false` to run the demo without the RViz window. Run one launch at a time and stop it with Ctrl+C before starting another. + +## Select a compute backend + +Change `compute_backend` when launching the demo. The installed plugin supports these choices without recompiling: + +| Value | Selection and prerequisites | +| --- | --- | +| `auto` (default) | Tries CUDA, then WGPU, then CPU | +| `wgpu` | Uses a WGPU adapter: Vulkan on Linux, Metal on native macOS | +| `cuda` | Uses an NVIDIA GPU; requires CUDA support in the plugin and a working driver/runtime | +| `cpu` | Computes acoustic returns on the CPU; Gazebo rendering is still required | + +Check the launch log for `Using sonar compute backend:` and the selected WGPU adapter. Explicit `wgpu` or `cuda` selection requires that backend to initialize. An unavailable backend or an invalid name disables that sonar sensor and reports an error. Choose `auto` to allow backend fallback, or `cpu` to request CPU processing directly. + +For the general sensor launch, set `POSIM_SONAR_COMPUTE_BACKEND` in the launching environment: ```bash -ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py +POSIM_SONAR_COMPUTE_BACKEND=cpu \ +ros2 launch posim_demos posim_sensor.launch.py \ + namespace:=blueview_p900 world_name:=posim_multibeam_sonar \ + paused:=false x:=4 z:=2.0 yaw:=3.14 gui:=false headless:=true ``` -Use one launch at a time. For the robot variant, select `bluerov2_heavy_multibeam_sonar` with a suitable sonar world. +Server mode also needs a rendering environment. Follow the [Docker rendering instructions](docker.md) for a virtual display or configured GPU access. For the robot variant, select `bluerov2_heavy_multibeam_sonar` with a suitable sonar world. ## Observe outputs @@ -24,9 +43,20 @@ Use one launch at a time. For the robot variant, select `bluerov2_heavy_multibea ros2 topic list -t ros2 topic echo /sensor/multibeam_sonar/sonar_image \ sensor_msgs/msg/Image --once --no-arr +ros2 topic hz /sensor/multibeam_sonar/sonar_image ``` -In RViz, add an Image display for the sonar image and PointCloud2 for `/sensor/multibeam_sonar/point_cloud`. Raw returns use the acoustic message interface supplied by `marine_acoustic_msgs`; discover their type with `ros2 topic list -t`. +| Topic | ROS 2 message | Use | +| --- | --- | --- | +| `/sensor/multibeam_sonar/sonar_image` | `sensor_msgs/msg/Image` | Display sonar intensity in RViz's Image display | +| `/sensor/multibeam_sonar/sonar_image_raw` | `marine_acoustic_msgs/msg/ProjectedSonarImage` | Read acoustic observations with range and beam information | +| `/sensor/multibeam_sonar/point_cloud` | `sensor_msgs/msg/PointCloud2` | Display the ray geometry in RViz's PointCloud2 display | + +The sonar publishes image and raw acoustic messages directly to ROS 2. The sensor configuration bridges the Gazebo point cloud to ROS 2. Discover custom topic names with `ros2 topic list -t`. + +## How the sensor works + +Gazebo supplies ray depths and surface normals for the scene. The sonar model computes acoustic backscatter, applies beam correction and compresses the returns into range bins. The resulting acoustic data and display image describe the same observation. Use raw data for analysis and retain the gain and display settings with recorded images. ## Sensor configuration @@ -51,7 +81,7 @@ The `` block controls `sonarFreq`, `bandwidth`, `soundSpeed`, `sourceLevel ## Add a sonar sensor and world -Copy `models/posim_sensor_models/description/blueview_p900/` to a new sensor identifier. Also prepare a matching `config//sensor_config.py`; align SDF model/link/frame names with bridge topics. Set horizontal/vertical samples, angles and range in `` to match your sensor specification. +Copy `models/posim_sensor_models/description/blueview_p900/` to a new sensor identifier. Also prepare a matching `config//sensor_config.py`; align SDF model/link/frame names with bridge topics. Set horizontal beams, vertical rays, angles and range in `` to match your sensor specification. Include the Gazebo rendering-sensor system and sonar system in the world. Consult the [sonar world SDF](https://github.com/IOES-Lab/POSIM/blob/main/models/posim_worlds/worlds/posim_multibeam_sonar.world) for physics, scene and other custom-sensor configuration. @@ -62,7 +92,7 @@ Include the Gazebo rendering-sensor system and sonar system in the world. Consul ``` -Prepare CUDA and rendering, rebuild/source the workspace, then select your descriptor using the [sensor launch arguments](quickstart.md). The ROS point-cloud bridge connects `sensor_msgs/msg/PointCloud2` to Gazebo `gz.msgs.PointCloudPacked`. Match `pointCloudTopicName` to the path configured in `sensor_config.py`. +Prepare the selected compute backend and rendering, rebuild/source the workspace, then select your descriptor using the [sensor launch arguments](quickstart.md). The ROS point-cloud bridge connects `sensor_msgs/msg/PointCloud2` to Gazebo `gz.msgs.PointCloudPacked`. Match `pointCloudTopicName` to the path configured in `sensor_config.py`. ## Record raw sonar data diff --git a/website/content/en/troubleshooting.md b/website/content/en/troubleshooting.md index b9c4c4f2..570e6109 100644 --- a/website/content/en/troubleshooting.md +++ b/website/content/en/troubleshooting.md @@ -17,7 +17,7 @@ Read the first missing URI in the log. Check that its package resources were ins ## No image or rendering initialization failure -Image sensors need a renderer even without a desktop window. Check display/offscreen settings, [Docker Xvfb setup](docker.md) and [CUDA libraries](sonar-tuning.md). +Image sensors need a renderer even without a desktop window. Check display/offscreen settings, [Docker Xvfb setup](docker.md) and [sonar backend setup](sonar-tuning.md). ## No messages diff --git a/website/content/ko/index.md b/website/content/ko/index.md index b4357428..c502d3af 100644 --- a/website/content/ko/index.md +++ b/website/content/ko/index.md @@ -29,7 +29,7 @@ POSIM은 [Project DAVE](https://field-robotics-lab.github.io/dave.doc/)를 계 | --- | --- | --- | | 해양 월드 | 수면·해저·작업 장면 | [월드 목록](worlds.md) | | 로봇 | REXROV, BlueROV2, Slocum, 수상 로봇 | [ROV](rovs.md), [글라이더](gliders.md), [수상 로봇](surface.md) | -| 센서 | 카메라·DVL·수압·USBL·CUDA 소나 | [카메라](camera.md), [DVL](dvl.md), [소나](sonar.md) | +| 센서 | 카메라·DVL·수압·USBL·멀티빔 소나 | [카메라](camera.md), [DVL](dvl.md), [소나](sonar.md) | | 환경 플러그인 | 해류·위도·경도 좌표 | [해류](currents.md), [좌표](coordinates.md) | | ROS 인터페이스 | 센서 구독·제어·실험 기록 | [ROS 2와 제어](ros.md) | diff --git a/website/content/ko/install.md b/website/content/ko/install.md index a49be70e..5f1c7080 100644 --- a/website/content/ko/install.md +++ b/website/content/ko/install.md @@ -47,6 +47,9 @@ rosdep install --rosdistro lyrical --from-paths src --ignore-src -r -y ## 4. 빌드하고 환경 불러오기 ```bash +colcon build --merge-install --executor sequential --symlink-install \ + --packages-select wgpu_vendor +source install/setup.bash colcon build --merge-install --executor sequential --symlink-install source install/setup.bash ros2 pkg prefix posim_demos @@ -60,7 +63,7 @@ source "$HOME/.ros_ardusub_env/env" source ~/posim_ws/install/setup.bash ``` -이어서 [첫 시뮬레이션](quickstart.md)을 실행하세요. NVIDIA/CUDA 소나는 [추가 빌드 설정](sonar-tuning.md)이 필요합니다. +이어서 [첫 시뮬레이션](quickstart.md)을 실행하세요. 소나 계산 방식은 [소나 빌드와 성능](sonar-tuning.md)에 따라 선택합니다. 메모리 부족으로 컴파일러가 강제 종료되면 패키지뿐 아니라 컴파일 작업 수도 줄이세요. `--executor sequential`은 패키지를 순서대로 빌드하지만, 패키지 안의 컴파일 작업 수까지 제한하지는 않습니다. diff --git a/website/content/ko/requirements.md b/website/content/ko/requirements.md index e9a80409..c519a7d1 100644 --- a/website/content/ko/requirements.md +++ b/website/content/ko/requirements.md @@ -21,7 +21,7 @@ Ubuntu 직접 설치 명령은 Bash에서 실행합니다. Docker 명령은 호 카메라·깊이 센서·DVL에는 렌더링 환경이 필요합니다. 화면 없이 실행할 때도 렌더러를 준비하세요. 첫 데이터 확인에는 소프트웨어 렌더링을 사용할 수 있습니다. -멀티빔 소나에는 NVIDIA GPU·CUDA 툴킷·소나 라이브러리가 필요합니다. [소나 빌드와 성능](sonar-tuning.md)을 참고하세요. +멀티빔 소나는 WGPU·CUDA·CPU 계산을 지원합니다. Rust·Cargo 라이브러리와 소나 플러그인을 빌드하고, 선택한 계산 방식과 Gazebo 렌더링을 준비하세요. [소나 빌드와 성능](sonar-tuning.md)을 참고하세요. ## 실험에 맞는 자원 준비 diff --git a/website/content/ko/sonar-tuning.md b/website/content/ko/sonar-tuning.md index 0ae58e27..1b5dd7e8 100644 --- a/website/content/ko/sonar-tuning.md +++ b/website/content/ko/sonar-tuning.md @@ -1,64 +1,139 @@ # 소나 빌드와 성능 -멀티빔 소나는 CUDA를 사용합니다. NVIDIA 드라이버·CUDA 컴파일러와 런타임·cuFFT·cuBLAS·Gazebo 렌더링을 준비하세요. +멀티빔 소나를 빌드한 뒤 실행할 때 WGPU·CUDA·CPU 계산 방식을 선택합니다. WGPU는 Linux에서 Vulkan, macOS에서 직접 실행할 때 Metal을 사용합니다. 모든 계산 방식에서 광선 센서의 Gazebo 렌더링이 필요합니다. -## 도구 확인 +## 도구 준비 -소나를 실행할 컴퓨터 또는 컨테이너 안에서 확인합니다. +[POSIM 작업 공간 설치 안내](install.md)에 따라 ROS 2 Lyrical·Gazebo Jetty·ROS–Gazebo 브리지를 준비합니다. `~/posim_ws`와 같은 작업 공간의 최상위 폴더에서 빌드하세요. + +- CPU 계산을 선택할 경우에도 WGPU 라이브러리를 빌드하기 위해 Rust와 Cargo가 필요합니다. +- Linux의 WGPU에는 Vulkan 로더와 GPU에 맞는 드라이버가 필요합니다. `vulkaninfo --summary`로 사용 가능한 어댑터를 확인합니다. +- CUDA에는 호환 NVIDIA GPU·드라이버·CUDA 컴파일러와 런타임·cuFFT·cuBLAS가 필요합니다. CMake가 툴킷을 찾으면 CUDA 지원을 포함합니다. +- 렌더링에는 Ogre2와 디스플레이·가상 디스플레이 또는 구성된 headless 렌더러가 필요합니다. + +```bash +rustc --version +cargo --version +``` + +Rust가 없으면 [공식 Rust 설치 안내](https://www.rust-lang.org/tools/install)를 따르세요. CUDA를 사용할 때는 다음 항목도 확인합니다. ```bash nvidia-smi nvcc --version ``` -GPU 접근과 컴파일러 설치는 각각 확인합니다. 컨테이너에서는 호스트의 [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)과 이미지 안의 라이브러리를 모두 준비해야 합니다. +GPU 접근과 컴파일러 설치는 각각 확인합니다. NVIDIA GPU를 사용하는 Linux 컨테이너에는 호스트의 [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html)과 컨테이너 내부의 드라이버·런타임 라이브러리도 필요합니다. + +## 빌드하고 환경 불러오기 + +새 Ubuntu 작업 공간에서는 WGPU 라이브러리를 먼저 빌드하고 환경을 불러온 뒤 나머지 패키지를 빌드합니다. + +```bash +source /opt/ros/lyrical/setup.bash +colcon build --merge-install --executor sequential --symlink-install \ + --packages-select wgpu_vendor +source install/setup.bash +colcon build --merge-install --executor sequential --symlink-install +source install/setup.bash +``` + +macOS에서 직접 빌드할 때도 해당 작업 공간의 ROS 환경을 불러온 뒤 같은 두 빌드 명령을 사용합니다. 컴파일러나 GPU 도구를 바꾸면 작업 공간을 다시 빌드하세요. ## CUDA 아키텍처 선택 -소나 CMake에는 기본값이 `60`인 `CUDA_ARCHITECTURE` 변수가 있습니다. GPU와 CUDA 툴킷에서 지원하는 SM 버전을 선택한 뒤 작업 공간에서 다시 빌드합니다. +소나의 CMake 변수 `CUDA_ARCHITECTURE` 기본값은 `native`입니다. CUDA 지원을 빌드할 때 현재 GPU의 아키텍처를 선택합니다. 다른 GPU에 배포할 경우 해당 GPU와 툴킷이 지원하는 SM 값을 지정하세요. ```bash colcon build --merge-install --executor sequential --symlink-install \ + --packages-select multibeam_sonar \ --cmake-args -DCUDA_ARCHITECTURE=YOUR_GPU_SM source install/setup.bash ``` -`YOUR_GPU_SM`을 해당 아키텍처 숫자로 바꿉니다. 툴킷이 오래된 타깃 지원을 종료했을 수 있으므로 지원 목록을 확인하세요. 이 프로젝트의 단수형 변수와 CMake의 `CMAKE_CUDA_ARCHITECTURES`는 구분합니다. +`YOUR_GPU_SM`을 아키텍처 숫자로 바꿉니다. 프로젝트는 이 값을 CMake의 `CMAKE_CUDA_ARCHITECTURES`로 전달합니다. 값을 선택하기 전에 툴킷의 지원 목록을 확인하세요. ## 설치 타깃 확인 ```bash +ros2 pkg prefix wgpu_vendor ros2 pkg prefix multibeam_sonar ros2 pkg prefix multibeam_sonar_system ros2 pkg prefix posim_multibeam_sonar_demo ``` -CUDA 타깃이 빌드되었는지 라이브러리 파일을 확인합니다. 패키지는 CUDA 타깃을 생략해도 등록될 수 있습니다. +Linux에서는 설치된 라이브러리를 확인합니다. ```bash +test -f "$(ros2 pkg prefix wgpu_vendor)/lib/libsonar_wgpu.a" test -f "$(ros2 pkg prefix multibeam_sonar)/lib/multibeam_sonar/libmultibeam_sonar.so" test -f "$(ros2 pkg prefix multibeam_sonar_system)/lib/multibeam_sonar_system/libmultibeam_sonar_system.so" ``` -각 명령의 종료 상태가 0인지 확인하세요. 빌드·실행 로그에서 타깃 생략과 로드 오류를 확인하고, [멀티빔 소나](sonar.md)에서 영상·원시 데이터를 받습니다. +각 명령의 종료 상태가 0이어야 합니다. [소나 예제](sonar.md)를 실행하여 로그의 계산 방식을 확인하고 영상·원시 데이터를 수신하세요. 패키지 경로나 라이브러리 파일의 존재만으로 GPU 접근까지 확인할 수는 없습니다. + +## WGPU 어댑터 선택 + +Gazebo를 실행하는 터미널에서 다음 환경 변수를 설정합니다. + +| 변수 | 동작 | +| --- | --- | +| `WGPU_BACKEND` | GPU API 선택. Linux는 `vulkan`, macOS에서 직접 실행할 때는 `metal` 등 | +| `WGPU_ADAPTER_NAME` | 대소문자 구분 없이 값이 이름에 포함된 첫 번째 어댑터 선택 | +| `WGPU_POWER_PREF` | `high`·`low`·`none` 전력 선호 설정. 기본값은 `high` | + +예를 들어 이름에 `RTX`가 포함된 Vulkan 어댑터를 선택합니다. + +```bash +WGPU_BACKEND=vulkan WGPU_ADAPTER_NAME=RTX WGPU_POWER_PREF=high \ +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu +``` + +컴퓨터에서 사용 가능한 어댑터 이름을 지정하세요. 일치하는 이름이 없으면 로그에 사용 가능한 이름을 출력합니다. 선택된 WGPU 어댑터는 같은 Gazebo 프로세스의 소나 센서들이 공유합니다. 어댑터를 바꾸려면 새 프로세스를 시작하세요. + +## Apple Silicon + +macOS에서 직접 실행하려면 Gazebo Jetty·Ogre2·ROS–Gazebo 브리지가 포함된 ROS 2 작업 공간의 환경을 불러옵니다. 위 안내에 따라 Rust·Cargo와 소나 패키지를 빌드하세요. WGPU 계산은 Metal을 통해 Apple GPU를 사용합니다. + +```bash +WGPU_BACKEND=metal \ +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu +``` + +Metal은 음향 계산을 담당하며, Gazebo의 렌더링 환경도 필요합니다. CPU에서 음향을 계산하려면 `compute_backend:=cpu`를 사용하세요. CUDA에는 호환 NVIDIA 환경이 필요합니다. + +Apple Silicon 호스트의 Linux ARM64 컨테이너는 [Docker 설치와 가상 디스플레이 렌더링](docker.md)을 따르세요. 컨테이너 안에서 `POSIM_SONAR_COMPUTE_BACKEND=cpu`를 지정하여 [서버 모드 소나 명령](sonar.md)을 실행합니다. Linux 컨테이너에서 WGPU를 사용하려면 Vulkan 어댑터가 필요합니다. 컨테이너의 계산 API는 macOS에서 직접 실행할 때의 Metal과 별도로 구성합니다. + +## 시작 오류 해결 + +| 증상 | 조치 | +| --- | --- | +| `wgpu_vendorConfig.cmake`를 찾지 못함 | `wgpu_vendor`를 빌드하고 `install/setup.bash`를 불러온 뒤 나머지 패키지 빌드 | +| WGPU에서 사용 가능한 어댑터가 없다고 표시 | GPU 드라이버·Linux의 `vulkaninfo --summary`·어댑터 선택 변수 확인. GPU 계산을 사용할 수 없으면 `auto`나 `cpu` 선택 | +| CUDA를 직접 선택했을 때 실패 | CUDA 빌드 지원·`nvidia-smi`·런타임 라이브러리·컨테이너 GPU 접근 확인 | +| 알 수 없는 계산 방식이라고 표시 | `auto`·`wgpu`·`cuda`·`cpu` 중 선택 | +| 같은 이름의 월드가 이미 실행 중이라고 표시 | 기존 POSIM Launch를 Ctrl+C로 종료한 뒤 다시 실행 | +| 계산 방식은 초기화되지만 영상이 오지 않음 | 월드의 일시 정지를 해제하고 렌더러와 [소나 토픽](sonar.md) 확인 | ## 한 번에 하나씩 조절 | 설정 | 확인할 영향 | | --- | --- | -| 수평 빔·수직 ray 수 | 각도 샘플링과 계산량 | +| 수평 빔·수직 광선 수 | 각도 샘플링과 계산량 | | 거리·`maxDistance` | 관측 범위와 거리 처리 | | `raySkips` | 샘플링과 계산량의 균형 | | 센서 `update_rate` | 요청하는 센서 처리율 | | `writeLog`, `debugFlag` | 디스크·콘솔 출력 부하 | | 센서 gain | 영상 표현. 물리적 반사 강도와 구분 | -같은 장면·자세에서 프레임 처리 시간, 수신 토픽 주기, Gazebo 실시간 비율, CPU·GPU와 메모리 사용량을 기록합니다. 물리 계산, 렌더링, CUDA 계산, 발행과 기록 비용을 구분하세요. 측정 전에 렌더러를 충분히 실행합니다. +같은 장면·자세에서 프레임 처리 시간, 수신 토픽 주기, Gazebo 실시간 비율과 CPU·GPU 사용량을 기록합니다. 물리 계산·렌더링·음향 계산·발행·기록 비용을 구분하세요. 렌더러를 충분히 실행한 뒤 측정합니다. 설정된 30 Hz는 요청 주기이며, 실제 수신 처리율은 장면·센서 설정·하드웨어에 따라 달라집니다. -계산 커널을 변경할 때는 기준 데이터를 보관합니다. 화면뿐 아니라 강도·거리 결과를 비교하세요. 관련 연구와 출처는 [인용 안내](citation.md)에 있습니다. +계산 방식이나 센서 설정을 비교할 때는 원시 강도·거리 데이터를 사용하고 gain·표시 설정을 일정하게 유지하세요. 연구 출처와 저자 표시는 [인용 안내](citation.md)에 있습니다. ## GPU 계산 흐름
소나 계산의 GPU 처리 단계
소나 계산의 GPU 처리 단계
-소나 계산은 산란점 합산, 창 함수·보정과 FFT 단계로 구성됩니다. 프로파일링할 때 각 단계의 실행 시간과 GPU 메모리 전송을 구분하세요. 그림 출처: POSIM Notion Wiki. +소나 계산은 후방 산란·빔 보정·거리 압축으로 구성됩니다. 프로파일링할 때 계산 시간과 메모리 전송을 구분하세요. 그림 출처: POSIM Notion Wiki. diff --git a/website/content/ko/sonar.md b/website/content/ko/sonar.md index dddb508e..e6d3098c 100644 --- a/website/content/ko/sonar.md +++ b/website/content/ko/sonar.md @@ -1,22 +1,41 @@ # 멀티빔 소나 -POSIM 멀티빔 소나는 ray 기반 점 산란 모델로 강도·거리 데이터와 소나 영상을 생성합니다. 실행 전에 [NVIDIA/CUDA 환경](sonar-tuning.md)을 준비하세요. +POSIM 멀티빔 소나는 광선 기반 점 산란 모델로 강도·거리 데이터, 소나 영상과 점군을 생성합니다. 실행할 때 WGPU·CUDA·CPU 계산 방식을 선택합니다. 먼저 [소나 빌드와 렌더링 환경](sonar-tuning.md)을 준비하세요. ## 소나 예제 실행 +POSIM 작업 공간의 환경을 불러온 터미널에서 WGPU로 BlueView P900 예제를 실행합니다. + ```bash -ros2 launch posim_demos posim_sensor.launch.py \ - namespace:=blueview_p900 world_name:=posim_multibeam_sonar \ - paused:=false x:=4 z:=2.0 yaw:=3.14 gui:=false headless:=true +ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py \ + compute_backend:=wgpu ``` -`posim_multibeam_sonar_demo` 패키지의 별도 예제도 있습니다. +예제는 Gazebo와 RViz를 엽니다. RViz에서 센서의 점군을 확인할 수 있습니다. RViz 창을 열지 않으려면 `rviz:=false`를 지정하세요. Launch는 하나씩 실행하고, 다음 Launch를 시작하기 전에 Ctrl+C로 종료합니다. + +## 계산 방식 선택 + +예제를 실행할 때 `compute_backend`를 지정합니다. 설치된 플러그인은 다시 컴파일하지 않고 다음 방식을 선택할 수 있습니다. + +| 값 | 선택 방식과 요구 사항 | +| --- | --- | +| `auto` (기본값) | CUDA, WGPU, CPU 순서로 사용 가능한 방식을 선택 | +| `wgpu` | WGPU 어댑터 사용. Linux는 Vulkan, macOS에서 직접 실행할 때는 Metal | +| `cuda` | NVIDIA GPU 사용. 플러그인의 CUDA 지원과 정상 작동하는 드라이버·런타임 필요 | +| `cpu` | CPU에서 음향 반사값 계산. Gazebo 렌더링도 필요 | + +실행 로그의 `Using sonar compute backend:`와 선택된 WGPU 어댑터를 확인하세요. `wgpu`나 `cuda`를 직접 지정하면 해당 방식이 초기화되어야 합니다. 사용할 수 없는 방식이나 잘못된 이름을 지정하면 해당 소나 센서를 비활성화하고 오류를 출력합니다. 자동 선택을 허용하려면 `auto`, CPU 계산을 직접 선택하려면 `cpu`를 사용하세요. + +일반 센서 Launch에서는 실행 환경의 `POSIM_SONAR_COMPUTE_BACKEND`를 설정합니다. ```bash -ros2 launch posim_multibeam_sonar_demo multibeam_sonar_demo.launch.py +POSIM_SONAR_COMPUTE_BACKEND=cpu \ +ros2 launch posim_demos posim_sensor.launch.py \ + namespace:=blueview_p900 world_name:=posim_multibeam_sonar \ + paused:=false x:=4 z:=2.0 yaw:=3.14 gui:=false headless:=true ``` -Launch는 하나씩 실행합니다. 로봇형 예제는 적절한 소나 월드에서 `bluerov2_heavy_multibeam_sonar`를 선택합니다. +서버 모드에도 렌더링 환경이 필요합니다. 가상 디스플레이나 GPU 연결은 [Docker 렌더링 안내](docker.md)를 따르세요. 로봇형 예제는 적절한 소나 월드에서 `bluerov2_heavy_multibeam_sonar`를 선택합니다. ## 출력 확인 @@ -24,9 +43,20 @@ Launch는 하나씩 실행합니다. 로봇형 예제는 적절한 소나 월드 ros2 topic list -t ros2 topic echo /sensor/multibeam_sonar/sonar_image \ sensor_msgs/msg/Image --once --no-arr +ros2 topic hz /sensor/multibeam_sonar/sonar_image ``` -RViz에서 소나 영상은 Image, `/sensor/multibeam_sonar/point_cloud`는 PointCloud2 디스플레이를 추가합니다. 원시 반사값은 `marine_acoustic_msgs`의 음향 메시지를 사용합니다. 정확한 자료형은 `ros2 topic list -t`로 찾습니다. +| 토픽 | ROS 2 메시지 | 용도 | +| --- | --- | --- | +| `/sensor/multibeam_sonar/sonar_image` | `sensor_msgs/msg/Image` | RViz의 Image 디스플레이에서 소나 강도 영상 표시 | +| `/sensor/multibeam_sonar/sonar_image_raw` | `marine_acoustic_msgs/msg/ProjectedSonarImage` | 거리·빔 정보를 포함한 음향 관측값 확인 | +| `/sensor/multibeam_sonar/point_cloud` | `sensor_msgs/msg/PointCloud2` | RViz의 PointCloud2 디스플레이에서 광선의 기하 정보 표시 | + +소나는 영상과 원시 음향 메시지를 ROS 2에 직접 발행합니다. 센서 설정의 브리지는 Gazebo 점군을 ROS 2로 전달합니다. 토픽 이름을 바꾼 경우 `ros2 topic list -t`로 확인하세요. + +## 센서 계산 흐름 + +Gazebo는 장면의 광선 거리와 표면 법선을 제공합니다. 소나 모델은 음향 후방 산란을 계산하고 빔 보정과 거리 압축을 수행합니다. 계산된 음향 데이터와 표시 영상은 같은 관측을 나타냅니다. 분석에는 원시 데이터를 사용하고, 영상과 함께 gain·표시 설정을 보관하세요. ## 센서 구성 @@ -35,7 +65,7 @@ RViz에서 소나 영상은 Image, `/sensor/multibeam_sonar/point_cloud`는 Poin | 설정 | 값 | | --- | --- | | 수평 빔 | 512개, 약 130° 시야 | -| 수직 ray | 300개, 약 12° 시야 | +| 수직 광선 | 300개, 약 12° 시야 | | 범위 | 0.1–10 m | | 주파수·대역폭 | 900 kHz / 29.9 kHz | | 음속 | 1500 m/s | @@ -45,13 +75,13 @@ RViz에서 소나 영상은 Image, `/sensor/multibeam_sonar/point_cloud`는 Poin ## 변경할 설정 -``에서 `sonarFreq`, `bandwidth`, `soundSpeed`, `sourceLevel`, `maxDistance`, `raySkips`, `sensorGain`과 출력 이름을 설정합니다. Ray 범위와 처리 거리를 맞추세요. `pointCloudTopicName`은 ROS–Gazebo 브리지 설정과 일치시키고 `frameName`에 소나 광학 좌표계를 지정합니다. +``에서 `sonarFreq`, `bandwidth`, `soundSpeed`, `sourceLevel`, `maxDistance`, `raySkips`, `sensorGain`과 출력 이름을 설정합니다. 광선 거리 범위와 처리 거리를 맞추세요. `pointCloudTopicName`은 ROS–Gazebo 브리지 설정과 일치시키고 `frameName`에 소나 광학 좌표계를 지정합니다. `writeLog`, `writeFrameInterval`, `debugFlag`로 진단 기록과 처리 시간을 확인할 수 있습니다. 기록 자체가 실험 목적이 아니라면 처리율 측정 시 기록을 끄세요. 반복 가능한 조절 순서는 [소나 빌드와 성능](sonar-tuning.md), 모델의 연구 출처는 [인용 안내](citation.md)에 있습니다. ## 소나 센서와 월드 추가하기 -`models/posim_sensor_models/description/blueview_p900/`을 새 센서 이름으로 복사합니다. 같은 이름의 `config/<센서 이름>/sensor_config.py`도 준비하고, SDF의 모델·링크·프레임과 브리지 토픽을 함께 맞춥니다. ``의 수평·수직 샘플 수와 각도, 거리 범위를 센서 사양에 맞게 설정합니다. +`models/posim_sensor_models/description/blueview_p900/`을 새 센서 이름으로 복사합니다. 같은 이름의 `config/<센서 이름>/sensor_config.py`도 준비하고, SDF의 모델·링크·프레임과 브리지 토픽을 함께 맞춥니다. ``의 수평 빔·수직 광선 수와 각도, 거리 범위를 센서 사양에 맞게 설정합니다. 월드에는 Gazebo 렌더링 센서 시스템과 소나 시스템을 포함합니다. 물리·장면·사용자 센서의 나머지 구성은 [소나 월드 SDF](https://github.com/IOES-Lab/POSIM/blob/main/models/posim_worlds/worlds/posim_multibeam_sonar.world)를 참고하세요. @@ -62,7 +92,7 @@ RViz에서 소나 영상은 Image, `/sensor/multibeam_sonar/point_cloud`는 Poin ``` -CUDA와 렌더링을 준비하고 작업 공간을 다시 빌드·source한 뒤, [센서 launch 인수](quickstart.md)를 사용해 새 descriptor를 선택합니다. ROS 점군 브리지는 `sensor_msgs/msg/PointCloud2`와 Gazebo `gz.msgs.PointCloudPacked`를 연결합니다. `pointCloudTopicName`과 `sensor_config.py`에 지정한 경로를 일치시키세요. +선택한 계산 방식과 렌더링을 준비하고 작업 공간을 다시 빌드하고 환경을 불러온 뒤, [센서 launch 인수](quickstart.md)를 사용해 새 센서 모델를 선택합니다. ROS 점군 브리지는 `sensor_msgs/msg/PointCloud2`와 Gazebo `gz.msgs.PointCloudPacked`를 연결합니다. `pointCloudTopicName`과 `sensor_config.py`에 지정한 경로를 일치시키세요. ## 원시 소나 데이터 기록하기 diff --git a/website/content/ko/troubleshooting.md b/website/content/ko/troubleshooting.md index eaacd03a..3275bf22 100644 --- a/website/content/ko/troubleshooting.md +++ b/website/content/ko/troubleshooting.md @@ -17,7 +17,7 @@ ros2 pkg prefix posim_demos ## 영상이 없거나 렌더링 초기화가 실패할 때 -영상 센서는 화면 없이 실행해도 렌더러가 필요합니다. 디스플레이·offscreen 설정을 확인하세요. [Docker 환경](docker.md)의 Xvfb 설정이나 [CUDA 라이브러리](sonar-tuning.md)를 확인합니다. +영상 센서는 화면 없이 실행해도 렌더러가 필요합니다. 디스플레이·offscreen 설정을 확인하세요. [Docker 환경](docker.md)의 Xvfb 설정이나 [소나 계산 환경](sonar-tuning.md)를 확인합니다. ## 메시지가 오지 않을 때 diff --git a/website/sources.json b/website/sources.json index d57c1cdd..0e5f7f81 100644 --- a/website/sources.json +++ b/website/sources.json @@ -1,5 +1,5 @@ { - "sourceRevision": "23bb608a9a89b9cda3472792316a81711f1ce73e", + "sourceRevision": "ad6165bd6c2b4e407f18ecb264e20bb76da8d4ca", "reviewedAt": "2026-10-09", "wiki": "https://caring-dibble-be5.notion.site/d24c9419989882cfa6498152ad4c840c?v=b42c941998988252a89588ad58631495&pvs=74", "notion": {
GPU stages in sonar processing
GPU stages in sonar processing