Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions gz_waves_rendering/CMakeLists.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
cmake_minimum_required(VERSION 3.16)
project(gz_waves_rendering)

if(NOT CMAKE_CXX_STANDARD)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
endif()

if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang")
add_compile_options(-Wall -Wextra -Wpedantic)
endif()

find_package(ament_cmake REQUIRED)
find_package(gz_sim_vendor REQUIRED)
find_package(gz-sim REQUIRED)
find_package(gz_waves REQUIRED) # IWaveField + WaveField2D + Wavefield component
find_package(gz_math_vendor REQUIRED)
find_package(gz-math REQUIRED)
find_package(gz_common_vendor REQUIRED)
find_package(gz-common REQUIRED)
find_package(gz_plugin_vendor REQUIRED)
find_package(gz-plugin REQUIRED COMPONENTS register)
find_package(gz_rendering_vendor REQUIRED)
find_package(gz-rendering REQUIRED COMPONENTS ogre2)
# NOTE: this package is provider-agnostic — it does NOT depend on the wave
# engines. WaterVisual rebuilds its private engine through the gz_waves core
# registry (CreateWaveSimulation); the GUI-side factory registration lives in
# the per-engine GUI plugins (gz-sim-waves-<engine>-gui), loaded by the world.

# Discover Ogre Next headers. Different Gazebo installs sit on different Ogre
# Next patch versions (ROS gz_ogre_next_vendor 2.3.3 vs Ubuntu libogre-next-dev
# 2.3.1). Compiling against one but loading the other silently swaps in slow
# Hlms/barrier code paths (no crash, but RTF tanks), so probe which Ogre Next
# the *resolved* gz-rendering-ogre2 links against and match our header path.
get_target_property(_gz_rend_ogre2_lib gz-rendering::ogre2 LOCATION)
if(_gz_rend_ogre2_lib MATCHES "/opt/ros/([^/]+)/")
set(_ros_distro "${CMAKE_MATCH_1}")
set(OGRE_NEXT_INCLUDE_DIR
"/opt/ros/${_ros_distro}/opt/gz_ogre_next_vendor/include/OGRE-Next"
CACHE PATH "Ogre Next include dir matched to gz-rendering-ogre2 distro")
else()
# Non-ROS-distro install (e.g. local jetty_ws build) → system Ogre Next.
set(OGRE_NEXT_INCLUDE_DIR "/usr/include/OGRE-2.3"
CACHE PATH "Ogre Next include dir from libogre-next-dev")
endif()
if(NOT EXISTS "${OGRE_NEXT_INCLUDE_DIR}/OgreRoot.h")
message(FATAL_ERROR
"OgreRoot.h not found under ${OGRE_NEXT_INCLUDE_DIR} "
"(probed from gz-rendering::ogre2 = ${_gz_rend_ogre2_lib}). "
"Install libogre-next-dev or gz_ogre_next_vendor as appropriate.")
endif()
message(STATUS "Ogre Next headers: ${OGRE_NEXT_INCLUDE_DIR}")

# The Ogre Next dynamic heightmap-texture helper lives in a SEPARATE shared
# library. Linking it directly into WaterVisual would force the plugin's
# DT_NEEDED to include libgz-rendering-ogre2.so, which interferes with
# gz-rendering's own engine-plugin loader (same SONAME already in process when
# gz-rendering tries to dlopen its engine plugin → the engine never initialises
# and the GUI shows a default-material plane). WaterVisual loads this bridge on
# demand via dlopen, so its symbols never enter the WaterVisual plugin's link
# map (mirrors the asv_wave_sim / gz_fft_waves rendering-ogre2 split).
add_library(waves-ogre2-bridge SHARED
src/systems/Ogre2HeightMapBridge.cc
)
target_link_libraries(waves-ogre2-bridge
PRIVATE
gz-rendering::gz-rendering
gz-rendering::ogre2
gz-common::gz-common
)
# SYSTEM so Ogre Next's own headers (extra ';', unused params, etc.) don't
# drown our -Wall -Wextra -Wpedantic output — they are upstream Ogre, not ours.
target_include_directories(waves-ogre2-bridge SYSTEM PRIVATE
${OGRE_NEXT_INCLUDE_DIR}
# HlmsPbs headers transitively include Hlms/Common/OgreHlmsBufferManager.h
# by unqualified name — that subdirectory must also be on the path.
${OGRE_NEXT_INCLUDE_DIR}/Hlms/Common
${OGRE_NEXT_INCLUDE_DIR}/Hlms/Pbs
)
# Ogre can't infer debug/release from our flags and emits a #pragma message in
# every TU; silence it (RelWithDebInfo is not an Ogre debug build).
target_compile_definitions(waves-ogre2-bridge PRIVATE OGRE_IGNORE_UNKNOWN_DEBUG)
install(
TARGETS waves-ogre2-bridge
LIBRARY DESTINATION lib
)

# WaterVisual: a gz-sim rendering system. Reads the wave field from the
# Wavefield component through IWaveField::Field() (gz_waves core) and uploads
# it as a displacement heightmap. Links only the engine-agnostic gz-rendering
# core; the Ogre2 bridge is loaded via dlopen (CMAKE_DL_LIBS).
add_library(gz-sim-water-visual-system SHARED
src/systems/WaterVisual.cc
src/systems/HeightMapTexture.cc
)
target_include_directories(gz-sim-water-visual-system PRIVATE
$<BUILD_INTERFACE:${CMAKE_CURRENT_SOURCE_DIR}/src/systems>
${gz_waves_INCLUDE_DIRS})
target_link_libraries(gz-sim-water-visual-system
PRIVATE
gz_waves::gz_waves # IWaveField + Field() + Wavefield component +
# CreateWaveSimulation (engine-agnostic registry)
gz-sim::core
gz-math::gz-math
gz-plugin::register
gz-rendering::gz-rendering
gz-common::gz-common
${CMAKE_DL_LIBS}
)
install(
TARGETS gz-sim-water-visual-system
LIBRARY DESTINATION lib
RUNTIME DESTINATION bin
)

# Water-surface model + shaders + meshes (model://water_surface).
install(
DIRECTORY share/models
DESTINATION share/${PROJECT_NAME}
)

# GZ_SIM_RESOURCE_PATH (water_surface model) + GZ_SIM_SYSTEM_PLUGIN_PATH hooks.
ament_environment_hooks("hooks/hook.dsv.in")
ament_environment_hooks("hooks/hook.sh.in")

if(BUILD_TESTING)
# Unit test for the pure column->row reflow helper on the heightmap upload
# path. Header-only and Ogre-free, so it links nothing but gtest.
find_package(ament_cmake_gtest REQUIRED)
ament_add_gtest(grid_reflow_test test/grid_reflow_test.cc)
target_include_directories(grid_reflow_test PRIVATE
${CMAKE_CURRENT_SOURCE_DIR}/src/systems)
endif()

ament_package()
2 changes: 2 additions & 0 deletions gz_waves_rendering/hooks/hook.dsv.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
prepend-non-duplicate;GZ_SIM_RESOURCE_PATH;@CMAKE_INSTALL_PREFIX@/share/@PROJECT_NAME@/models
prepend-non-duplicate;GZ_SIM_SYSTEM_PLUGIN_PATH;@CMAKE_INSTALL_PREFIX@/lib
2 changes: 2 additions & 0 deletions gz_waves_rendering/hooks/hook.sh.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
_colcon_prepend_unique_value GZ_SIM_RESOURCE_PATH @CMAKE_INSTALL_PREFIX@/share/@PROJECT_NAME@/models
_colcon_prepend_unique_value GZ_SIM_SYSTEM_PLUGIN_PATH @CMAKE_INSTALL_PREFIX@/lib
35 changes: 35 additions & 0 deletions gz_waves_rendering/package.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?xml version="1.0"?>
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
<package format="3">
<name>gz_waves_rendering</name>
<version>0.0.0</version>
<description>
Rendering consumer for gz_waves: the WaterVisual gz-sim system and its
Ogre2 heightmap bridge + water-surface model/shaders. Draws the wave field
read from the Wavefield component through IWaveField::Field() — engine
agnostic. Engine-specific Ogre2 code is isolated in a dlopen'd bridge.
</description>
<maintainer email="cen.aguero@gmail.com">Carlos Agüero</maintainer>
<license>Apache-2.0</license>

<buildtool_depend>ament_cmake</buildtool_depend>

<!-- Provider-agnostic: depends only on the gz_waves core. WaterVisual
rebuilds its private engine via the core registry (CreateWaveSimulation);
the per-engine GUI plugins (gz-sim-waves-<engine>-gui) supply the
factories at runtime, so no build dependency on any wave engine. -->
<depend>gz_waves</depend>
<depend>gz_sim_vendor</depend>
<depend>gz_math_vendor</depend>
<depend>gz_common_vendor</depend>
<depend>gz_plugin_vendor</depend>
<depend>gz_rendering_vendor</depend>

<test_depend>ament_cmake_gtest</test_depend>
<test_depend>ament_lint_auto</test_depend>
<test_depend>ament_lint_common</test_depend>

<export>
<build_type>ament_cmake</build_type>
</export>
</package>
67 changes: 67 additions & 0 deletions gz_waves_rendering/share/models/water_surface/meshes/water.dae

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions gz_waves_rendering/share/models/water_surface/model.config
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?xml version="1.0"?>
<model>
<name>water_surface</name>
<version>1.0</version>
<sdf version="1.10">model.sdf</sdf>
<description>
Water surface visual driven by the Gerstner shader. Reads the
Wavefield component from the world entity.
</description>
</model>
56 changes: 56 additions & 0 deletions gz_waves_rendering/share/models/water_surface/model.sdf
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?xml version="1.0"?>
<sdf version="1.10">
<model name="water_surface">
<static>true</static>
<link name="link">
<visual name="water">
<geometry>
<mesh>
<uri>meshes/water.dae</uri>
</mesh>
</geometry>
<plugin filename="gz-sim-water-visual-system"
name="gz::sim::systems::WaterVisual">
<shader>
<!-- Active shader pair. The visual uploads a CPU-generated
heightmap; these displace and shade the surface. The
vertex shader is engine-agnostic — FFT and Gerstner
both feed the same grid. -->
<fft_vertex>shaders/fft_water_vs_330.glsl</fft_vertex>
<fragment>shaders/water_fs_330.glsl</fragment>
<parameters>
<rescale>0.5</rescale>
<!-- Effective bumpmap tiling is bumpScale × 16 in the
VS (see fft_water_vs_330.glsl). 64 × 16 = 1024
bumpmap repeats per uv unit. -->
<bumpScale>64 64</bumpScale>
<bumpSpeed>0.01 0.01</bumpSpeed>
<hdrMultiplier>0.4</hdrMultiplier>
<fresnelPower>5.0</fresnelPower>
<shallowColor>0 0.1 0.3 1.0</shallowColor>
<deepColor>0 0.05 0.2 1.0</deepColor>
</parameters>
</shader>
<textures>
<bumpMap>textures/wave_normals.dds</bumpMap>
<cubeMap>textures/skybox_lowres.dds</cubeMap>
</textures>
</plugin>

<!-- Per-engine GUI registrars. They must sit in the same <visual> as
WaterVisual: that is where the GUI's GuiRunner loads systems —
model/link-level system plugins never reach the GUI process. Each
registers its factory at library load so WaterVisual can rebuild a
private engine from the replicated recipe via CreateWaveSimulation.
They carry no ISystem interface and parse no SDF (registration is a
static initializer that runs when the GUI dlopens the library), so
order is irrelevant and they add no plugin warnings. One registrar
per available engine; gz_waves_rendering itself depends on no engine,
so these plugins carry that dependency. The FFT registrar
(gz-sim-waves-fft-gui) is added alongside when the FFT backend lands. -->
<plugin filename="gz-sim-waves-gerstner-gui"
name="gz::sim::systems::GerstnerWavesGui"/>
</visual>
</link>
</model>
</sdf>
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (C) 2026 Honu Robotics
//
// Licensed under the Apache License, Version 2.0 (the "License");
//
// Tessendorf ocean vertex shader. Reads a single RGBA32F heightmap
// where each texel packs (η, Dx, Dy, _) and uses it to displace the
// vertex both vertically (η) and laterally (Dx, Dy scaled by
// chopFactor).
//
// Per-vertex surface normal is computed via central differences on
// the same heightmap — sample four world-space-neighbour cells, take
// the cross product of the two finite-difference vectors, normalise.
// The tangent basis (T, B, N) is forwarded to the fragment shader as
// a rotation matrix.
//
// Bumpmap UV uses a hardcoded `bumpResolution` factor (matches the
// asv_wave_sim convention of a dense per-uv-unit tiling): the
// effective bumpScale is `bumpScale × bumpResolution`. With the
// default bumpScale of (64, 64), that's 1024 bumpmap repeats per uv
// unit — plenty of micro-texture detail.

#version 330

in vec4 vertex;
in vec4 uv0;

uniform mat4 worldviewproj_matrix;
uniform mat4 world_matrix;
uniform vec3 camera_position_object_space;
uniform float t;
uniform float tau;
uniform float rescale;
uniform vec2 bumpScale;
uniform vec2 bumpSpeed;

uniform float tileSize;
uniform int gridSize;
uniform float chopFactor;
uniform sampler2D heightMap;

out block
{
mat3 rotMatrix;
vec3 eyeVec;
vec2 bumpCoord;
// Undisplaced world XY at the vertex — the FS uses this to sample
// the heightmap per-fragment for the foam mask, applying fract()
// in the FS so triangle interpolation across a tile boundary
// doesn't sweep the UV backwards through the texture.
vec2 baseXY;
} outVs;

out gl_PerVertex
{
vec4 gl_Position;
};

// Sample the heightmap at a WORLD-space XY position. Using world
// coordinates (rather than model-space `vertex.xy`) is what lets us
// instance the same mesh as multiple tiles without each tile
// rendering the same wavefield patch — the FFT field is naturally
// periodic across world space.
vec3 SampleDisplaced(vec2 worldXY)
{
vec2 uv = fract(worldXY / tileSize);
vec4 hd = texture(heightMap, uv);
vec2 dxy = chopFactor * hd.gb;
return vec3(worldXY + dxy, hd.r);
}

void main()
{
vec4 P = vertex;
vec2 worldXY = (world_matrix * vec4(vertex.xy, 0.0, 1.0)).xy;

vec3 disp = SampleDisplaced(worldXY);
// Apply only the spatial displacement deltas (Dx, Dy, η) so the
// mesh's model-space position picks up the right local offset
// before the world transform turns it into world coordinates.
P.xy += disp.xy - worldXY;
P.z += disp.z;

// Surface normal via central differences on neighbouring displaced
// samples of the heightmap (the only path; analytic slope maps were
// never wired up on the CPU side).
float texel = tileSize / float(gridSize);
vec3 px = SampleDisplaced(worldXY + vec2( texel, 0.0));
vec3 nx = SampleDisplaced(worldXY + vec2(-texel, 0.0));
vec3 py = SampleDisplaced(worldXY + vec2(0.0, texel));
vec3 ny = SampleDisplaced(worldXY + vec2(0.0, -texel));
vec3 dxv = (px - nx) * 0.5;
vec3 dyv = (py - ny) * 0.5;
vec3 N = normalize(cross(dxv, dyv));
if (N.z < 0.0) N = -N;
vec3 T = normalize(dxv - dot(dxv, N) * N);
if (length(T) < 1e-4) T = vec3(1.0, 0.0, 0.0);
vec3 B = cross(N, T);
outVs.rotMatrix = mat3(B * rescale, T * rescale, N);

gl_Position = worldviewproj_matrix * P;

// Bumpmap tiling. ×16 matches asv_wave_sim's reference scaling.
// WaterVisual patches the bumpMap sampler with anisotropic
// trilinear filtering at material-binding time, so the dense
// tiling reads correctly at distance via mipmap LOD.
const float bumpResolution = 16.0;
outVs.bumpCoord = uv0.xy * bumpScale * bumpResolution + t * bumpSpeed;

// Eye vector in object space. Tile transforms are pure translation
// so the direction vector matches world space — which is what the
// FS needs for cubemap sampling.
outVs.eyeVec = P.xyz - camera_position_object_space;

// Forward the unfracted world XY so the FS can compute the
// heightmap UV per-fragment. We pass the undisplaced position
// (worldXY, before the chop offset) so neighbouring samples for
// the foam mask are taken in the source domain.
outVs.baseXY = worldXY;
}
Loading