diff --git a/gazebo/posim_gz_rendering/Appearance.hh b/gazebo/posim_gz_rendering/Appearance.hh new file mode 100644 index 0000000..60b6b54 --- /dev/null +++ b/gazebo/posim_gz_rendering/Appearance.hh @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include +#include +#include +namespace posim +{ +// Stable wire order; schema and revision precede user settings. +using Appearance = std::array; +inline Appearance Defaults() { return {1, 0, 1, 1, 0, 0, 1, 1, 1, 1, 0, 10, 5}; } +inline bool Valid(const Appearance & a) +{ + for (auto v : a) + { + if (!std::isfinite(v)) + { + return false; + } + } + if (a[0] != 1 || a[1] < 0 || a[1] > 9007199254740991. || std::floor(a[1]) != a[1]) + { + return false; + } + for (unsigned i = 2; i <= 10; ++i) + { + if (a[i] != 0 && a[i] != 1) + { + return false; + } + } + return a[11] >= 1 && a[11] <= 500 && a[12] >= 1 && a[12] <= 50; +} +inline double WaterPath(double cameraZ, double surfaceZ, double distance) +{ + if (distance <= 0) + { + return 0; + } + if (cameraZ <= 0 && surfaceZ <= 0) + { + return distance; + } + if (cameraZ >= 0 && surfaceZ >= 0) + { + return 0; + } + return distance * (cameraZ < 0 ? -cameraZ : -surfaceZ) / std::abs(surfaceZ - cameraZ); +} +} // namespace posim diff --git a/gazebo/posim_gz_rendering/CMakeLists.txt b/gazebo/posim_gz_rendering/CMakeLists.txt new file mode 100644 index 0000000..060fb9a --- /dev/null +++ b/gazebo/posim_gz_rendering/CMakeLists.txt @@ -0,0 +1,44 @@ +cmake_minimum_required(VERSION 3.22) +project(posim_gz_rendering LANGUAGES CXX) +find_package(gz-rendering REQUIRED COMPONENTS ogre2) +find_package(gz-sim REQUIRED) +find_package(gz-plugin REQUIRED COMPONENTS register) +find_package(PkgConfig REQUIRED) +pkg_check_modules(OGRE REQUIRED IMPORTED_TARGET OGRE-Next>=2.3) +pkg_check_modules(HLMS REQUIRED IMPORTED_TARGET OGRE-Next-Hlms) +find_path( + HLMS_COMMON OgreHlmsTextureBaseClass.h + HINTS ${HLMS_INCLUDE_DIRS} ${OGRE_INCLUDE_DIRS} + PATH_SUFFIXES Common ../Common Hlms/Common include/OGRE-Next/Hlms/Common + OGRE-Next/Hlms/Common REQUIRED) +add_library(posim_underwater_render SHARED UnderwaterRenderer.cc) +target_compile_features(posim_underwater_render PRIVATE cxx_std_17) +target_include_directories(posim_underwater_render PRIVATE ${HLMS_COMMON}) +target_link_libraries( + posim_underwater_render + PRIVATE gz-rendering::gz-rendering gz-rendering::ogre2 PkgConfig::OGRE + PkgConfig::HLMS) +add_library(posim_underwater SHARED UnderwaterSystem.cc) +target_compile_features(posim_underwater PRIVATE cxx_std_17) +target_link_libraries( + posim_underwater PRIVATE gz-sim::gz-sim gz-plugin::register + gz-rendering::gz-rendering ${CMAKE_DL_LIBS}) +set_target_properties( + posim_underwater posim_underwater_render + PROPERTIES PREFIX "") +install(TARGETS posim_underwater posim_underwater_render + LIBRARY DESTINATION lib) +install(DIRECTORY shaders/ DESTINATION share/posim/underwater) +include(CTest) +if(BUILD_TESTING) + add_executable(underwater-settings-test tests/AppearanceTest.cc) + target_compile_features(underwater-settings-test PRIVATE cxx_std_17) + target_include_directories(underwater-settings-test PRIVATE .) + add_test(NAME underwater-settings COMMAND underwater-settings-test) + add_executable(underwater-render-test tests/RenderTest.cc) + target_compile_features(underwater-render-test PRIVATE cxx_std_17) + target_include_directories(underwater-render-test PRIVATE .) + target_link_libraries( + underwater-render-test PRIVATE posim_underwater_render + gz-rendering::gz-rendering) +endif() diff --git a/gazebo/posim_gz_rendering/README.ko.md b/gazebo/posim_gz_rendering/README.ko.md new file mode 100644 index 0000000..2fd4759 --- /dev/null +++ b/gazebo/posim_gz_rendering/README.ko.md @@ -0,0 +1,31 @@ +# 수중 표면 렌더링 + +[English](README.md) + +Gazebo Jetty / Ogre2 시스템이 PBR 표면을 그릴 때 수중 경로 안개, RGB 흡수, 수심에 따른 햇빛 감쇠, 움직이는 해저면 햇빛 무늬와 선택 가능한 부유 입자를 적용합니다. RGB 카메라와 RGBD 컬러 영상은 같은 재질 경로를 사용합니다. 수치 깊이와 충돌 형상은 측정값을 유지합니다. Gazebo의 클리핑, 단색 센서 출력과 지형 그림자 기능을 유지합니다. + +수면은 z = 0 m 평면으로 근사합니다. 햇빛 무늬와 부유 입자는 계산량이 적은 절차적 근사입니다. 하늘 배경에도 수중 경로 감쇠가 적용되며 깊이 측정은 유지됩니다. 비조명 객체와 사용자 수면 셰이더는 자체 표현을 사용합니다. 로봇 조명의 직접 조명은 유지하며, 표면과 카메라 사이의 물을 통과하는 빛은 감쇠합니다. + +서버와 같은 Gazebo/Ogre SDK에서 CMake로 빌드합니다. 시스템 라이브러리는 Gazebo가 렌더링 장면을 만든 뒤 렌더링 보조 라이브러리를 불러옵니다. 라이브러리는 `lib`, 셰이더는 `share/posim/underwater`에 설치합니다. + +```xml + + /absolute/lib/posim_underwater_render.so + /absolute/share/posim/underwater + unique_session_id + +``` + +macOS 라이브러리 확장자는 `.dylib`입니다. Gazebo 프로세스마다 한 인스턴스를 사용하세요. 렌더러는 엔진의 PBS 리스너를 확장하고 렌더링 종료 시 복원합니다. + +`/posim/appearance//set`은 `gz.msgs.Double_V`를 받습니다. 13개 값의 순서는 스키마(1), 리비전, 수중 광학 효과, 해저면 질감, 격자선, 등고선, 안개, 색상 흡수, 햇빛 감쇠, 햇빛 무늬, 부유 입자, 격자 간격, 등고선 간격입니다. 논리값은 0 또는 1입니다. 리비전은 정확히 1씩 증가하며 같은 요청의 재전송은 중복 적용하지 않습니다. `/state`는 `gz.msgs.Empty`를 받아 13개 값 뒤에 준비 여부, 카메라 수, 렌더러 실패 여부를 반환합니다. 렌더링 후 적용 상태를 확인합니다. + +- 기본 켜짐: 광학 효과, 질감, 안개, 색상 흡수, 햇빛 감쇠, 햇빛 무늬. +- 기본 꺼짐: 격자선, 등고선, 부유 입자. +- 격자 간격: 기본 10 m, 범위 1–500 m. +- 등고선 간격: 기본 5 m, 범위 1–50 m. +- 수중 광학 효과를 꺼도 질감과 측정선 선택은 유지됩니다. + +해저면 시각 요소의 이름은 `seabed` 또는 `shallow`이며 Gazebo 범위 이름도 지원합니다. 지형 내보내기에서 PBR 맵을 제공합니다. 질감과 측정선은 이 시각 요소의 해수면 아래 부분에 적용됩니다. `POSIM_UNDERWATER_PLUGIN`이 설정되면 기존 `UnderwaterCamera` 호환 출력은 네이티브 컬러 영상을 그대로 전달합니다. 호스트는 네이티브 시스템을 설치한 경우에만 이 값을 설정하고 렌더러 실패를 사용자에게 표시해야 합니다. + +`underwater-settings-test`는 설정과 수중 경로 경계를 검사합니다. `underwater-render-test SHADER_DIRECTORY OUTPUT_DIRECTORY SAND_MAP_DIRECTORY`는 RGB·RGBD 비교 영상을 만들고 설정을 바꾸어도 깊이 데이터가 바이트 단위로 같은지 확인합니다. POSIM-Terrain의 모래 맵과 설정된 Gazebo 환경에서 실행하세요. diff --git a/gazebo/posim_gz_rendering/README.md b/gazebo/posim_gz_rendering/README.md new file mode 100644 index 0000000..15c6c4f --- /dev/null +++ b/gazebo/posim_gz_rendering/README.md @@ -0,0 +1,31 @@ +# Underwater surface rendering + +[한국어](README.ko.md) + +This Gazebo Jetty / Ogre2 system applies water-path haze, RGB absorption, depth-dependent sunlight, animated seabed caustics and optional suspended specks while PBR surfaces are shaded. RGB cameras and RGBD color use the same material path. Numeric depth and collision geometry retain their measured values. Gazebo's clipping, solid-color sensor output and terrain-shadow hooks are retained. + +The water surface is approximated by the plane z = 0 m. Caustics and specks are inexpensive procedural approximations. Sky rays use water-path attenuation without changing depth measurements. Unlit objects and custom water shaders retain their own appearance. Local lamps keep their direct illumination; water between a surface and a camera still attenuates that surface's light. + +Build with CMake against the same Gazebo/Ogre SDK as the server. The system library delays loading its rendering helper until Gazebo creates a rendering scene. Install libraries to `lib` and shader pieces to `share/posim/underwater`. + +```xml + + /absolute/lib/posim_underwater_render.so + /absolute/share/posim/underwater + unique_session_id + +``` + +On macOS the libraries have the `.dylib` suffix. Use one instance per Gazebo process. The renderer extends the engine's PBS listener and restores it during render teardown. + +`/posim/appearance//set` accepts `gz.msgs.Double_V`. Its 13 values are schema (1), revision, water optics, seabed texture, grid, contours, haze, absorption, sunlight fading, caustics, specks, grid spacing and contour spacing. Boolean values are 0 or 1. Revision advances by exactly one; an identical retry is idempotent. `/state` accepts `gz.msgs.Empty` and returns those 13 values plus ready, camera count and renderer failed. Applied state is acknowledged after rendering. + +- Defaults on: optics, texture, haze, absorption, sunlight fading, caustics. +- Defaults off: grid, contours, suspended specks. +- Grid: 10 m, adjustable from 1 to 500 m. +- Contours: 5 m, adjustable from 1 to 50 m. +- Turning water optics off preserves texture and measurement-overlay selections. + +Seabed visuals are named `seabed` or `shallow` (including Gazebo scoped names). Terrain exports supply their PBR maps. Texture detail and measurement overlays affect these visuals below sea level. The legacy `UnderwaterCamera` compatibility output passes native-colored pixels through when `POSIM_UNDERWATER_PLUGIN` is set. Hosts should enable this flag only when they install the native system, and surface renderer failures to users. + +`underwater-settings-test` covers configuration and water-path boundaries. `underwater-render-test SHADER_DIRECTORY OUTPUT_DIRECTORY SAND_MAP_DIRECTORY` renders RGB and RGBD comparisons and requires byte-identical depth across settings. Run it in a configured Gazebo environment with the sand maps distributed by POSIM-Terrain. diff --git a/gazebo/posim_gz_rendering/UnderwaterRenderer.cc b/gazebo/posim_gz_rendering/UnderwaterRenderer.cc new file mode 100644 index 0000000..91bb5da --- /dev/null +++ b/gazebo/posim_gz_rendering/UnderwaterRenderer.cc @@ -0,0 +1,365 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "UnderwaterRenderer.hh" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +namespace posim +{ +struct UnderwaterRenderer::Impl : Ogre::HlmsListener, Ogre::SceneManager::Listener +{ + Ogre::Hlms * pbs = nullptr; + Ogre::ArchiveVec original; + Ogre::HlmsListener * previous = nullptr; + Appearance settings = Defaults(); + double time = 0; + unsigned cameras = 0; + std::map skyPrograms; + Ogre::HighLevelGpuProgramPtr skyProgram; + bool skyDirty = false; + Ogre::SceneManager * manager = nullptr; + void preFindVisibleObjects( + Ogre::SceneManager * source, Ogre::SceneManager::IlluminationRenderStage stage, + Ogre::Viewport *) override + { + if (stage != Ogre::SceneManager::IRS_NONE) + { + return; + } + auto camera = source->getCamerasInProgress().renderingCamera; + if (!camera) + { + return; + } + for (auto & entry : skyPrograms) + { + auto material = Ogre::MaterialManager::getSingleton().getByName(entry.first); + if (material) + { + material->getTechnique(0)->getPass(0)->getFragmentProgramParameters()->setNamedConstant( + "cameraPosition", camera->getDerivedPosition()); + } + } + } + void Sky(Ogre::Material * material) + { + if (!material || !material->getNumTechniques() || !material->getTechnique(0)->getNumPasses()) + { + return; + } + auto pass = material->getTechnique(0)->getPass(0); + if (!pass->hasFragmentProgram() || !pass->getNumTextureUnitStates()) + { + return; + } + if ( + pass->getFragmentProgramName().find("SkyBox") == std::string::npos && + pass->getFragmentProgramName() != "POSIM/UnderwaterSky") + { + return; + } + if (!skyProgram) + { + const bool metal = + Ogre::Root::getSingleton().getRenderSystem()->getName().find("Metal") != std::string::npos; + skyProgram = Ogre::HighLevelGpuProgramManager::getSingleton().createProgram( + "POSIM/UnderwaterSky", Ogre::ResourceGroupManager::DEFAULT_RESOURCE_GROUP_NAME, + metal ? "metal" : "glsl", Ogre::GPT_FRAGMENT_PROGRAM); + if (metal) + { + skyProgram->setSource(R"( +#include +using namespace metal; +struct Input { float3 cameraDir; }; +struct Params { float3 cameraPosition; float4 controls; }; +fragment float4 main_metal(Input input [[stage_in]], constant Params &p [[buffer(PARAMETER_SLOT)]], + texturecube sky [[texture(0)]], sampler state [[sampler(0)]]) { + float3 ray=normalize(input.cameraDir); + float3 color=sky.sample(state,float3(ray.xy,-ray.z)).rgb; + float water=p.cameraPosition.z<0.0 ? (ray.z>0.001 ? -p.cameraPosition.z/ray.z : 200.0) : 0.0; + float3 transmission=exp(-water*float3(.18,.09,.06)*p.controls.x); + return float4(color*transmission+float3(.006,.055,.065)*(1.0-transmission),1.0); +})"); + } + else + { + skyProgram->setSource(R"( +#version 330 +uniform samplerCube skyCubemap; +uniform vec3 cameraPosition; +uniform vec4 controls; +in block { vec3 cameraDir; } inPs; +out vec3 fragColour; +void main() { + vec3 ray=normalize(inPs.cameraDir); + vec3 color=texture(skyCubemap,vec3(ray.xy,-ray.z)).rgb; + float water=cameraPosition.z<0.0 ? (ray.z>0.001 ? -cameraPosition.z/ray.z : 200.0) : 0.0; + vec3 transmission=exp(-water*vec3(.18,.09,.06)*controls.x); + fragColour=color*transmission+vec3(.006,.055,.065)*(1.0-transmission); +})"); + } + if (metal) + { + skyProgram->setParameter("entry_point", "main_metal"); + skyProgram->setParameter("shader_reflection_pair_hint", "SkyBox_vs_Metal"); + } + skyProgram->load(); + } + if (!skyPrograms.count(material->getName())) + { + skyPrograms[material->getName()] = pass->getFragmentProgram(); + } + if (pass->getFragmentProgramName() != skyProgram->getName()) + { + pass->setFragmentProgram(skyProgram->getName()); + skyDirty = true; + } + auto parameters = pass->getFragmentProgramParameters(); + parameters->setNamedConstant("cameraPosition", Ogre::Vector3(0, 0, 0)); + parameters->setNamedConstant("controls", Ogre::Vector4(settings[2] * settings[6], 0, 0, 0)); + if (parameters->_findNamedConstantDefinition("skyCubemap", false)) + { + parameters->setNamedConstant("skyCubemap", 0); + } + } + explicit Impl(const std::string & pieces) + { + auto root = gz::rendering::Ogre2RenderEngine::Instance()->OgreRoot(); + pbs = root->getHlmsManager()->getHlms(Ogre::HLMS_PBS); + if (!pbs) + { + throw std::runtime_error("underwater_pbs_unavailable"); + } + previous = pbs->getListener(); + if (dynamic_cast(previous)) + { + throw std::runtime_error("underwater_already_installed"); + } + original = pbs->getPiecesLibraryAsArchiveVec(); + auto extended = original; + extended.push_back(Ogre::ArchiveManager::getSingleton().load(pieces, "FileSystem", true)); + pbs->setListener(this); + try + { + pbs->reloadFrom(pbs->getDataFolder(), &extended); + } + catch (...) + { + pbs->setListener(previous); + throw; + } + } + ~Impl() + { + if (manager) + { + manager->removeListener(this); + } + for (auto & entry : skyPrograms) + { + auto material = Ogre::MaterialManager::getSingleton().getByName(entry.first); + if (material) + { + material->getTechnique(0)->getPass(0)->setFragmentProgram(entry.second->getName()); + } + } + if (skyProgram) + { + Ogre::Root::getSingleton() + .getHlmsManager() + ->getHlms(Ogre::HLMS_LOW_LEVEL) + ->_clearShaderCache(); + } + if (pbs && pbs->getListener() == this) + { + pbs->setListener(previous); + pbs->reloadFrom(pbs->getDataFolder(), &original); + } + } + void propertiesMergedPreGenerationStep( + Ogre::Hlms * h, const Ogre::HlmsCache & c, const Ogre::HlmsPropertyVec & r, + const Ogre::PiecesMap p[Ogre::NumShaderTypes], const Ogre::HlmsPropertyVec & v, + const Ogre::QueuedRenderable & q) override + { + if (previous) + { + previous->propertiesMergedPreGenerationStep(h, c, r, p, v, q); + } + } + Ogre::uint16 getNumExtraPassTextures(const Ogre::HlmsPropertyVec & p, bool c) const override + { + return previous ? previous->getNumExtraPassTextures(p, c) : 0; + } + void setupRootLayout(Ogre::RootLayout & r, const Ogre::HlmsPropertyVec & p) const override + { + if (previous) + { + previous->setupRootLayout(r, p); + } + } + void shaderCacheEntryCreated( + const Ogre::String & s, const Ogre::HlmsCache * h, const Ogre::HlmsCache & c, + const Ogre::HlmsPropertyVec & p, const Ogre::QueuedRenderable & q) override + { + if (previous) + { + previous->shaderCacheEntryCreated(s, h, c, p, q); + } + } + void preparePassHash( + const Ogre::CompositorShadowNode * s, bool c, bool d, Ogre::SceneManager * m, + Ogre::Hlms * h) override + { + if (previous) + { + previous->preparePassHash(s, c, d, m, h); + } + } + void hlmsTypeChanged( + bool c, Ogre::CommandBuffer * b, const Ogre::HlmsDatablock * d, size_t t) override + { + if (previous) + { + previous->hlmsTypeChanged(c, b, d, t); + } + } + Ogre::uint32 getPassBufferSize( + const Ogre::CompositorShadowNode * s, bool c, bool d, Ogre::SceneManager * m) const override + { + return (previous ? previous->getPassBufferSize(s, c, d, m) : 0) + (c ? 0 : 128); + } + float * preparePassBuffer( + const Ogre::CompositorShadowNode * s, bool caster, bool d, Ogre::SceneManager * scene, + float * out) override + { + if (previous) + { + out = previous->preparePassBuffer(s, caster, d, scene, out); + } + if (caster) + { + return out; + } + auto camera = scene->getCamerasInProgress().renderingCamera; + auto inverse = camera->getViewMatrix(true).inverse(); + for (unsigned i = 0; i < 3; ++i) + { + for (unsigned j = 0; j < 4; ++j) + { + *out++ = inverse[i][j]; + } + } + for (auto i : {2, 4, 5, 3}) + { + *out++ = settings[i]; + } + for (auto i : {6, 7, 8, 9}) + { + *out++ = settings[i]; + } + *out++ = settings[11]; + *out++ = settings[12]; + *out++ = time; + *out++ = settings[10]; + for (float v : {.18f, .09f, .06f, 0.f}) + { + *out++ = v; + } + for (float v : {.006f, .055f, .065f, 1.f}) + { + *out++ = v; + } + return out; + } +}; +UnderwaterRenderer::UnderwaterRenderer(const std::string & pieces) : impl(new Impl(pieces)) {} +UnderwaterRenderer::~UnderwaterRenderer() = default; +unsigned UnderwaterRenderer::Cameras() const { return impl->cameras; } +void UnderwaterRenderer::Update( + const gz::rendering::ScenePtr & scene, const Appearance & settings, double time) +{ + if (!impl->manager) + { + auto native = std::dynamic_pointer_cast(scene); + impl->manager = native->OgreSceneManager(); + impl->manager->addListener(impl.get()); + } + impl->settings = settings; + impl->time = time; + impl->cameras = 0; + auto materials = Ogre::MaterialManager::getSingleton().getResourceIterator(); + while (materials.hasMoreElements()) + { + impl->Sky(dynamic_cast(materials.getNext().get())); + } + if (impl->skyDirty) + { + Ogre::Root::getSingleton().getHlmsManager()->getHlms(Ogre::HLMS_LOW_LEVEL)->_clearShaderCache(); + impl->skyDirty = false; + } + for (unsigned i = 0; i < scene->SensorCount(); ++i) + { + if (std::dynamic_pointer_cast(scene->SensorByIndex(i))) + { + ++impl->cameras; + } + } + for (unsigned i = 0; i < scene->VisualCount(); ++i) + { + auto v = scene->VisualByIndex(i); + const auto & name = v->Name(); + const auto scope = name.rfind("::"); + const auto leaf = name.substr(scope == std::string::npos ? 0 : scope + 2); + const bool seabed = leaf == "seabed" || leaf == "shallow"; + if (!seabed) + { + continue; + } + for (unsigned g = 0; g < v->GeometryCount(); ++g) + { + auto m = + std::dynamic_pointer_cast(v->GeometryByIndex(g)->Material()); + if (m && m->Datablock() && m->Datablock()->getUserValue(0).x != 6421) + { + m->Datablock()->setUserValue(0, Ogre::Vector4(6421, 0, 0, 0)); + } + } + } +} +} // namespace posim +extern "C" void * posimCreateUnderwater(const char * path) +{ + return new posim::UnderwaterRenderer(path); +} +extern "C" unsigned posimUpdateUnderwater( + void * p, const gz::rendering::ScenePtr & s, const posim::Appearance & a, double t) +{ + auto r = static_cast(p); + r->Update(s, a, t); + return r->Cameras(); +} +extern "C" void posimDestroyUnderwater(void * p) +{ + delete static_cast(p); +} diff --git a/gazebo/posim_gz_rendering/UnderwaterRenderer.hh b/gazebo/posim_gz_rendering/UnderwaterRenderer.hh new file mode 100644 index 0000000..3ae1287 --- /dev/null +++ b/gazebo/posim_gz_rendering/UnderwaterRenderer.hh @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: Apache-2.0 +#pragma once +#include +#include +#include +#include "Appearance.hh" +namespace posim +{ +class UnderwaterRenderer +{ + struct Impl; + std::unique_ptr impl; + +public: + explicit UnderwaterRenderer(const std::string & pieces); + ~UnderwaterRenderer(); + void Update(const gz::rendering::ScenePtr &, const Appearance &, double simulationTime); + unsigned Cameras() const; +}; +} // namespace posim diff --git a/gazebo/posim_gz_rendering/UnderwaterSystem.cc b/gazebo/posim_gz_rendering/UnderwaterSystem.cc new file mode 100644 index 0000000..159501a --- /dev/null +++ b/gazebo/posim_gz_rendering/UnderwaterSystem.cc @@ -0,0 +1,233 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "Appearance.hh" +#ifdef _WIN32 +#include +#else +#include +#endif +namespace posim +{ +class Underwater final : public gz::sim::System, + public gz::sim::ISystemConfigure, + public gz::sim::ISystemPostUpdate +{ + std::mutex mutex; + Appearance requested = Defaults(), applied = Defaults(), staged = Defaults(); + bool stagedReady = false; + unsigned stagedCameras = 0; + double time = 0; + unsigned cameras = 0; + bool ready = false, failed = false; + void *library = nullptr, *renderer = nullptr; + using Create = void * (*)(const char *); + using Update = unsigned (*)(void *, const gz::rendering::ScenePtr &, const Appearance &, double); + using Destroy = void (*)(void *); + Create create = nullptr; + Update update = nullptr; + Destroy destroy = nullptr; + std::string path, pieces; + gz::transport::Node node; + gz::common::ConnectionPtr renderConnection, postRenderConnection, teardownConnection; + void Close() + { + if (renderer && destroy) + { + destroy(renderer); + renderer = nullptr; + } + } + +public: + ~Underwater() override + { + renderConnection.reset(); + postRenderConnection.reset(); + teardownConnection.reset(); + // The rendering resources are destroyed by RenderTeardown, on their owner thread. + // Keep the helper mapped until process exit if the engine omitted teardown. + if (!renderer && library) + { +#ifdef _WIN32 + FreeLibrary(static_cast(library)); +#else + dlclose(library); +#endif + } + } + void Configure( + const gz::sim::Entity &, const std::shared_ptr & sdf, + gz::sim::EntityComponentManager &, gz::sim::EventManager & events) override + { + if ( + !sdf->HasElement("renderer_library") || !sdf->HasElement("shader_directory") || + !sdf->HasElement("session_nonce")) + { + gzerr << "Underwater requires renderer_library, shader_directory and session_nonce\n"; + return; + } + path = sdf->Get("renderer_library"); + pieces = sdf->Get("shader_directory"); + if (sdf->HasElement("initial_settings")) + { + std::istringstream stream(sdf->Get("initial_settings")); + Appearance initial; + for (auto & v : initial) + { + if (!(stream >> v)) + { + throw std::runtime_error("invalid_initial_appearance"); + } + } + if (!Valid(initial)) + { + throw std::runtime_error("invalid_initial_appearance"); + } + requested = applied = initial; + } + auto service = "/posim/appearance/" + sdf->Get("session_nonce"); + node.Advertise(service + "/set", &Underwater::Set, this); + node.Advertise(service + "/state", &Underwater::State, this); + renderConnection = events.Connect([this] { Render(); }); + postRenderConnection = events.Connect( + [this] + { + std::lock_guard lock(mutex); + if (stagedReady && !failed) + { + applied = staged; + cameras = stagedCameras; + ready = cameras > 0; + stagedReady = false; + } + }); + teardownConnection = events.Connect( + [this] + { + Close(); + std::lock_guard lock(mutex); + ready = false; + }); + } + bool Set(const gz::msgs::Double_V & request, gz::msgs::Double_V & response) + { + std::lock_guard lock(mutex); + if (request.data_size() != 13) + { + return false; + } + Appearance a; + std::copy(request.data().begin(), request.data().end(), a.begin()); + if ( + !Valid(a) || a[1] < requested[1] || a[1] > requested[1] + 1 || + (a[1] == requested[1] && a != requested)) + { + return false; + } + requested = a; + for (auto v : requested) + { + response.add_data(v); + } + return true; + } + bool State(const gz::msgs::Empty &, gz::msgs::Double_V & response) + { + std::lock_guard lock(mutex); + for (auto v : applied) + { + response.add_data(v); + } + response.add_data(ready ? 1 : 0); + response.add_data(cameras); + response.add_data(failed ? 1 : 0); + return true; + } + void PostUpdate( + const gz::sim::UpdateInfo & info, const gz::sim::EntityComponentManager &) override + { + std::lock_guard lock(mutex); + time = std::chrono::duration(info.simTime).count(); + } + void Render() + { + if (failed) + { + return; + } + auto scene = gz::rendering::sceneFromFirstRenderEngine(); + if (!scene) + { + return; + } + try + { + if (scene->Engine()->Name() != "ogre2") + { + throw std::runtime_error("underwater_requires_ogre2"); + } + if (!renderer) + { +#ifdef _WIN32 + library = LoadLibraryA(path.c_str()); + auto symbol = [this](const char * name) + { return reinterpret_cast(GetProcAddress(static_cast(library), name)); }; +#else + library = dlopen(path.c_str(), RTLD_NOW | RTLD_LOCAL); + auto symbol = [this](const char * name) { return dlsym(library, name); }; +#endif + if (!library) + { + throw std::runtime_error("underwater_render_library_unavailable"); + } + create = reinterpret_cast(symbol("posimCreateUnderwater")); + update = reinterpret_cast(symbol("posimUpdateUnderwater")); + destroy = reinterpret_cast(symbol("posimDestroyUnderwater")); + if (!create || !update || !destroy) + { + throw std::runtime_error("underwater_render_abi_mismatch"); + } + renderer = create(pieces.c_str()); + } + Appearance a; + double t; + { + std::lock_guard lock(mutex); + a = requested; + t = time; + } + auto count = update(renderer, scene, a, t); + { + std::lock_guard lock(mutex); + staged = a; + stagedCameras = count; + stagedReady = true; + } + } + catch (const std::exception & error) + { + std::lock_guard lock(mutex); + failed = true; + ready = false; + gzerr << "Underwater disabled: " << error.what() << "\n"; + } + } +}; +} // namespace posim +GZ_ADD_PLUGIN( + posim::Underwater, gz::sim::System, posim::Underwater::ISystemConfigure, + posim::Underwater::ISystemPostUpdate) +GZ_ADD_PLUGIN_ALIAS(posim::Underwater, "posim::Underwater") diff --git a/gazebo/posim_gz_rendering/package.xml b/gazebo/posim_gz_rendering/package.xml new file mode 100644 index 0000000..b6f8be0 --- /dev/null +++ b/gazebo/posim_gz_rendering/package.xml @@ -0,0 +1,10 @@ + + + posim_gz_rendering0.1.0 + Shared underwater surface optics for Gazebo Ogre2 cameras. + Woen-Sug Choi + Apache-2.0 + cmake + gz_sim_vendorgz_rendering_vendorgz_plugin_vendor + cmake + diff --git a/gazebo/posim_gz_rendering/shaders/Underwater_piece_vs_piece_ps.any b/gazebo/posim_gz_rendering/shaders/Underwater_piece_vs_piece_ps.any new file mode 100644 index 0000000..5e013b6 --- /dev/null +++ b/gazebo/posim_gz_rendering/shaders/Underwater_piece_vs_piece_ps.any @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: Apache-2.0 +// PBR surface optics: RGB and RGBD colour share the same linear radiance path. +@pset( custom_disable_directional_lights, 1 ) +@property( !hlms_shadowcaster ) +@undefpiece( custom_passBuffer ) +@piece( custom_passBuffer ) + @insertpiece( custom_passBuffer_spherical_clip_min_distance ) + @insertpiece( custom_passBuffer_terra ) + float4 uwInverse0; float4 uwInverse1; float4 uwInverse2; + float4 uwControls; float4 uwOptics; float4 uwParams; + float4 uwCoefficients; float4 uwScatter; +@end +@piece( custom_ps_posMaterialLoad ) + float3 uwWorld = float3(dot(passBuf.uwInverse0,float4(inPs.pos,1.0)),dot(passBuf.uwInverse1,float4(inPs.pos,1.0)),dot(passBuf.uwInverse2,float4(inPs.pos,1.0))); + float uwDepth = max(0.0,passBuf.uwCoefficients.w-uwWorld.z); + float uwSeabed = abs(material.userValue[0].x-6421.0)<0.5 && uwDepth>0.0 ? 1.0 : 0.0; +@end +@piece( custom_ps_posSampleNormal ) + if(uwSeabed>0.5){ + if(passBuf.uwControls.w<0.5){ + pixelData.diffuse.xyz=float3(0.32,0.27,0.19)*0.318309886; + @property( normal_map )pixelData.normal=float3(0.0,0.0,1.0);@end + } + float2 cell=uwWorld.xy/passBuf.uwParams.x; + float2 footprint=max(abs(OGRE_ddx(cell))+abs(OGRE_ddy(cell)),float2(0.00001,0.00001)); + float2 edge=abs(fract(cell-0.5)-0.5)/footprint; + float grid=(1.0-smoothstep(0.35,1.2,min(edge.x,edge.y)))*(1.0-smoothstep(0.25,0.6,max(footprint.x,footprint.y))); + float level=uwWorld.z/passBuf.uwParams.y; + float fw=max(abs(OGRE_ddx(level))+abs(OGRE_ddy(level)),0.00001); + float contour=(1.0-smoothstep(0.45*fw,1.35*fw,abs(fract(level-0.5)-0.5)))*(1.0-smoothstep(0.25,0.6,fw)); + pixelData.diffuse.xyz*=1.0-max(grid*passBuf.uwControls.y*0.72,contour*passBuf.uwControls.z*0.88); + } +@end +@undefpiece( custom_ps_preLights ) +@piece( custom_ps_preLights ) + @property( terra_enabled ) + @property( hlms_lights_directional && hlms_num_shadow_map_lights )fShadow *= inPs.terrainShadow; + @else float fShadow = inPs.terrainShadow;@end + @end + float3 uwAmbient=finalColour; + finalColour=float3(0.0,0.0,0.0); + @insertpiece( DoDirectionalLights ) + if(passBuf.uwControls.x>0.5 && uwDepth>0.0){ + if(passBuf.uwOptics.z>0.5) finalColour*=exp(-passBuf.uwCoefficients.xyz*uwDepth); + if(passBuf.uwOptics.w>0.5 && uwSeabed>0.5){ + float2 p=uwWorld.xy*1.4; + float t=passBuf.uwParams.z*0.35; + float a=sin(p.x+sin(p.y*1.3+t)*1.5+t); + float b=sin(p.y+sin(p.x*1.1-t)*1.5-t); + float caustic=pow(max(0.0,1.0-abs(a+b)*0.65),8.0); + finalColour*=1.0+caustic*0.45*exp(-uwDepth*0.12); + } + } + finalColour+=uwAmbient; +@end +@undefpiece( custom_ps_posExecution ) +@piece( custom_ps_posExecution ) + @property( !hlms_render_depth_only && !hlms_prepass && (hlms_normal || hlms_qtangent) ) + if(passBuf.uwControls.x>0.5){ + float cameraZ=passBuf.uwInverse2.w-passBuf.uwCoefficients.w; + float targetZ=uwWorld.z-passBuf.uwCoefficients.w; + float distance=length(inPs.pos); + float fraction=0.0; + if(cameraZ<=0.0 && targetZ<=0.0)fraction=1.0; + else if(cameraZ*targetZ<0.0)fraction=(cameraZ<0.0?-cameraZ:-targetZ)/max(abs(targetZ-cameraZ),0.00001); + float water=distance*fraction; + float3 attenuation=passBuf.uwOptics.y>0.5?passBuf.uwCoefficients.xyz:float3(0.06,0.06,0.06); + if(passBuf.uwOptics.x<0.5 && passBuf.uwOptics.y<0.5)attenuation=float3(0.0,0.0,0.0); + float3 transmission=exp(-water*attenuation); + // Three cheap, world-anchored dust slices; visual approximation, no sensor noise. + float specks=0.0; + if(passBuf.uwParams.w>0.5 && cameraZ<0.0){ + float3 origin=float3(passBuf.uwInverse0.w,passBuf.uwInverse1.w,passBuf.uwInverse2.w); + float3 ray=(uwWorld-origin)/max(distance,0.0001); + for(int layer=1;layer<=3;++layer){ + float travel=float(layer)*0.8; + float3 point=origin+ray*travel; + if(travel0.97?(1.0-smoothstep(0.015,0.015+radius,length(delta)))*0.035:0.0; + } + } + } + float3 radiance=outPs_colour0.xyz; + @property( !hw_gamma_write )radiance*=radiance;@end + radiance=radiance*transmission+passBuf.uwScatter.xyz*(1.0-transmission)*passBuf.uwOptics.x; + radiance+=float3(specks,specks,specks); + @property( !hw_gamma_write )radiance=sqrt(max(radiance,float3(0.0,0.0,0.0)));@end + outPs_colour0.xyz=radiance; + } + @end + @insertpiece( custom_ps_posExecution_solid_color ) +@end +@end diff --git a/gazebo/posim_gz_rendering/tests/AppearanceTest.cc b/gazebo/posim_gz_rendering/tests/AppearanceTest.cc new file mode 100644 index 0000000..401f5f3 --- /dev/null +++ b/gazebo/posim_gz_rendering/tests/AppearanceTest.cc @@ -0,0 +1,34 @@ +// SPDX-License-Identifier: Apache-2.0 +#include "Appearance.hh" +int main() +{ + auto a = posim::Defaults(); + if (!posim::Valid(a)) + { + return 1; + } + for (unsigned i = 2; i <= 10; ++i) + { + auto bad = a; + bad[i] = 0.5; + if (posim::Valid(bad)) + { + return 2; + } + } + for (auto bad : {-1., 0.5, 9007199254740992.}) + { + a[1] = bad; + if (posim::Valid(a)) + { + return 3; + } + } + if ( + posim::WaterPath(-2, -8, 10) != 10 || posim::WaterPath(2, 8, 10) != 0 || + posim::WaterPath(2, -8, 10) != 8 || posim::WaterPath(-2, 8, 10) != 2) + { + return 4; + } + return 0; +} diff --git a/gazebo/posim_gz_rendering/tests/RenderTest.cc b/gazebo/posim_gz_rendering/tests/RenderTest.cc new file mode 100644 index 0000000..3dbafd8 --- /dev/null +++ b/gazebo/posim_gz_rendering/tests/RenderTest.cc @@ -0,0 +1,231 @@ +// SPDX-License-Identifier: Apache-2.0 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include "UnderwaterRenderer.hh" +int main(int argc, char ** argv) +{ + if (argc != 4) + { + return 2; + } +#ifdef __APPLE__ + auto engine = gz::rendering::engine("ogre2", {{"metal", "1"}}); +#else + auto engine = gz::rendering::engine("ogre2", {{"headless", "1"}}); +#endif + if (!engine) + { + return 3; + } + auto scene = engine->CreateScene("underwater-test"); + scene->SetSkyEnabled(true); + scene->SetAmbientLight(.4, .4, .4); + scene->SetBackgroundColor(.01, .03, .04); + auto light = scene->CreateDirectionalLight(); + light->SetDirection(.3, .3, -1); + light->SetDiffuseColor(1, 1, 1); + scene->RootVisual()->AddChild(light); + auto seabed = scene->CreateVisual("seabed"); + seabed->AddGeometry(scene->CreateBox()); + seabed->SetLocalScale(100, 100, .2); + seabed->SetLocalPosition(0, 0, -8); + auto sand = scene->CreateMaterial(); + sand->SetDiffuse(.6, .5, .3); + sand->SetRoughness(.9); + sand->SetTexture(std::string(argv[3]) + "/aerial_beach_01_diff_2k.jpg"); + sand->SetNormalMap(std::string(argv[3]) + "/aerial_beach_01_nor_gl_2k.jpg"); + seabed->SetMaterial(sand); + scene->RootVisual()->AddChild(seabed); + auto target = scene->CreateVisual(); + target->AddGeometry(scene->CreateBox()); + target->SetLocalPosition(5, 0, -6); + auto red = scene->CreateMaterial(); + red->SetDiffuse(1, .05, .05); + target->SetMaterial(red); + scene->RootVisual()->AddChild(target); + auto camera = scene->CreateCamera(); + camera->SetImageWidth(640); + camera->SetImageHeight(360); + camera->SetHFOV(1.05); + camera->SetAspectRatio(640. / 360); + camera->SetLocalPosition(-2, 0, -3); + camera->SetLocalRotation(0, .3, 0); + scene->RootVisual()->AddChild(camera); + auto depth = scene->CreateDepthCamera(); + depth->SetImageWidth(640); + depth->SetImageHeight(360); + depth->SetHFOV(1.05); + depth->SetAspectRatio(640. / 360); + depth->SetNearClipPlane(.1); + depth->SetFarClipPlane(100); + depth->SetLocalPose(camera->LocalPose()); + scene->RootVisual()->AddChild(depth); + std::vector measured, baseline; + std::vector rgb(640 * 360 * 3); + auto connection = depth->ConnectNewDepthFrame( + [&](const float * data, unsigned w, unsigned h, unsigned, const std::string &) + { measured.assign(data, data + w * h); }); + auto colors = depth->ConnectNewRgbPointCloud( + [&](const float * data, unsigned w, unsigned h, unsigned, const std::string &) + { + for (unsigned i = 0; i < w * h; ++i) + { + uint32_t c; + std::memcpy(&c, data + i * 4 + 3, 4); + rgb[i * 3] = (c >> 24) & 255; + rgb[i * 3 + 1] = (c >> 16) & 255; + rgb[i * 3 + 2] = (c >> 8) & 255; + } + }); + camera->Update(); + depth->Update(); + if (measured.empty()) + { + return 4; + } + baseline = measured; + { + posim::UnderwaterRenderer renderer(argv[1]); + auto settings = posim::Defaults(); + std::vector untreated, waterOnly; + for (int mode = 0; mode < 5; ++mode) + { + settings[2] = mode == 0 ? 0 : 1; + settings[4] = mode == 2 ? 1 : 0; + settings[10] = mode == 3 ? 1 : 0; + settings[3] = mode == 4 ? 0 : 1; + settings[11] = 5; + renderer.Update(scene, settings, 10.); + auto image = camera->CreateImage(); + for (int i = 0; i < 3; ++i) + { + camera->Capture(image); + } + std::ofstream file( + std::string(argv[2]) + "/native-" + std::to_string(mode) + ".ppm", std::ios::binary); + file << "P6\n640 360\n255\n"; + file.write(reinterpret_cast(image.Data()), 640 * 360 * 3); + depth->Update(); + if ( + measured.size() != baseline.size() || + std::memcmp(measured.data(), baseline.data(), baseline.size() * sizeof(float))) + { + return 5; + } + auto pixels = image.Data(); + unsigned long delta = 0, agreement = 0; + for (size_t i = 0; i < rgb.size(); ++i) + { + agreement += std::abs(int(pixels[i]) - int(rgb[i])); + if (mode > 0) + { + delta += std::abs(int(pixels[i]) - int(mode == 1 ? untreated[i] : waterOnly[i])); + } + } + if (agreement > rgb.size() * 2) + { + return 6; + } + if (mode == 0) + { + untreated.assign(pixels, pixels + rgb.size()); + } + if (mode == 1) + { + waterOnly.assign(pixels, pixels + rgb.size()); + } + if (mode > 0 && delta < 100) + { + return 7; + } + std::ofstream rgbFile( + std::string(argv[2]) + "/rgbd-" + std::to_string(mode) + ".ppm", std::ios::binary); + rgbFile << "P6\n640 360\n255\n"; + rgbFile.write(reinterpret_cast(rgb.data()), rgb.size()); + auto start = std::chrono::steady_clock::now(); + for (int i = 0; i < 40; ++i) + { + camera->Capture(image); + depth->Update(); + } + std::cout << "mode " << mode << " RGB + RGBD ms " + << std::chrono::duration( + std::chrono::steady_clock::now() - start) + .count() / + 40 + << " depth unchanged" << std::endl; + } + seabed->SetVisible(false); + target->SetVisible(false); + camera->SetLocalRotation(0, 0, 0); + depth->SetLocalPose(camera->LocalPose()); + for (int location = 0; location < 2; ++location) + { + camera->SetLocalPosition(-2, 0, location ? 3 : -3); + depth->SetLocalPose(camera->LocalPose()); + std::vector skyOff; + std::vector skyDepth; + for (int enabled = 0; enabled < 2; ++enabled) + { + settings = posim::Defaults(); + settings[2] = enabled; + renderer.Update(scene, settings, 10.); + auto image = camera->CreateImage(); + camera->Capture(image); + camera->Capture(image); + depth->Update(); + auto pixels = image.Data(); + unsigned long skyAgreement = 0; + for (size_t i = 0; i < rgb.size(); ++i) + { + skyAgreement += std::abs(int(pixels[i]) - int(rgb[i])); + } + if (skyAgreement / double(rgb.size()) > 2) + { + return 10; + } + std::ofstream file( + std::string(argv[2]) + "/sky-" + std::to_string(location) + "-" + + std::to_string(enabled) + ".ppm", + std::ios::binary); + file << "P6\n640 360\n255\n"; + file.write(reinterpret_cast(pixels), 640 * 360 * 3); + if (!enabled) + { + skyOff.assign(pixels, pixels + 640 * 360 * 3); + skyDepth = measured; + } + else + { + unsigned long difference = 0; + for (size_t i = 0; i < skyOff.size(); ++i) + { + difference += std::abs(int(pixels[i]) - int(skyOff[i])); + } + std::cout << "sky location " << location << " delta " << difference << std::endl; + if ((location == 0 && difference < 10000) || (location == 1 && difference != 0)) + { + return 8; + } + if (std::memcmp(skyDepth.data(), measured.data(), measured.size() * sizeof(float))) + { + return 9; + } + } + } + } + } + engine->DestroyScene(scene); + return 0; +} diff --git a/gazebo/posim_gz_sensor_plugins/src/UnderwaterCamera.cc b/gazebo/posim_gz_sensor_plugins/src/UnderwaterCamera.cc index 2688622..864c209 100644 --- a/gazebo/posim_gz_sensor_plugins/src/UnderwaterCamera.cc +++ b/gazebo/posim_gz_sensor_plugins/src/UnderwaterCamera.cc @@ -15,6 +15,7 @@ */ #include "posim_gz_sensor_plugins/UnderwaterCamera.hh" +#include #include #include #include @@ -468,6 +469,13 @@ void UnderwaterCamera::DepthImageCallback(const gz::msgs::Image & msg) cv::Mat UnderwaterCamera::SimulateUnderwater( const cv::Mat & _inputImage, const cv::Mat & _inputDepth, cv::Mat & _outputImage) { + // The scene renderer already applies optics to the raw RGB topic. Preserve + // this compatibility output without attenuating the same pixels twice. + if (std::getenv("POSIM_UNDERWATER_PLUGIN")) + { + _inputImage.copyTo(_outputImage); + return _outputImage; + } const float * lutPtr = this->dataPtr->depth2rangeLUT; for (unsigned int row = 0; row < this->dataPtr->height; row++) { diff --git a/website/content/en/camera.md b/website/content/en/camera.md index 23db4da..41ee12f 100644 --- a/website/content/en/camera.md +++ b/website/content/en/camera.md @@ -4,6 +4,10 @@ The underwater-camera plugin applies distance-dependent color attenuation to ren ## Run the example +The [POSIM underwater renderer](https://github.com/IOES-Lab/POSIM/tree/main/gazebo/posim_gz_rendering) provides shared scene optics for Ogre2 PBR surfaces. It applies water-path haze, color absorption, depth-dependent sunlight and animated seabed light patterns to RGB cameras and RGBD color. Numeric depth remains unchanged. WWW-POSIM exposes live switches for the Gazebo scene and camera feeds. + +The renderer approximates mean sea level by z = 0 m. Sky rays use underwater path attenuation. Unlit materials and custom water shaders use their own rendering. When the host enables the native renderer with `POSIM_UNDERWATER_PLUGIN`, the camera plugin passes its already-colored input through to the compatibility output. + ```bash ros2 launch posim_demos posim_sensor.launch.py \ namespace:=underwater_camera world_name:=camera_tutorial \ diff --git a/website/content/ko/camera.md b/website/content/ko/camera.md index a0d6a18..f65ec23 100644 --- a/website/content/ko/camera.md +++ b/website/content/ko/camera.md @@ -4,6 +4,10 @@ ## 예제 실행 +[POSIM 수중 렌더러](https://github.com/IOES-Lab/POSIM/tree/main/gazebo/posim_gz_rendering)는 Ogre2 PBR 표면의 광학 효과를 공유합니다. 수중 경로 안개, 색상 흡수, 수심에 따른 햇빛 감쇠와 움직이는 해저면 햇빛 무늬를 RGB 카메라와 RGBD 컬러 영상에 적용합니다. 수치 깊이는 유지합니다. WWW-POSIM에서는 Gazebo 장면과 카메라 영상에 적용할 효과를 실시간 스위치로 바꿉니다. + +렌더러는 평균 해수면을 z = 0 m로 근사합니다. 하늘에도 수중 경로 감쇠를 적용합니다. 비조명 재질과 사용자 수면 셰이더는 자체 렌더링을 사용합니다. 호스트에서 `POSIM_UNDERWATER_PLUGIN`으로 네이티브 렌더러를 활성화하면 카메라 플러그인은 이미 색상이 적용된 입력을 호환 출력으로 그대로 전달합니다. + ```bash ros2 launch posim_demos posim_sensor.launch.py \ namespace:=underwater_camera world_name:=camera_tutorial \