diff --git a/Rendering/VolumeOpenGL2/CMakeLists.txt b/Rendering/VolumeOpenGL2/CMakeLists.txt
index c9164fb4c36db9251aa372e123a313d4eda7408b..9dd5ea45923c0b7ef2c333b859982fca1ca9350a 100644
--- a/Rendering/VolumeOpenGL2/CMakeLists.txt
+++ b/Rendering/VolumeOpenGL2/CMakeLists.txt
@@ -4,6 +4,7 @@ set(classes
   vtkOpenGLGPUVolumeRayCastMapper
   vtkOpenGLProjectedTetrahedraMapper
   vtkOpenGLRayCastImageDisplayHelper
+  vtkOpenGLSurfaceProbeVolumeMapper
   vtkSmartVolumeMapper
   vtkVolumeTexture)
 
diff --git a/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.cxx b/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.cxx
new file mode 100644
index 0000000000000000000000000000000000000000..2c13531ec6caaae038f3d56beb404cff015bdebb
--- /dev/null
+++ b/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.cxx
@@ -0,0 +1,636 @@
+// SPDX-FileCopyrightText: Copyright (c) Ken Martin, Will Schroeder, Bill Lorensen
+// SPDX-License-Identifier: BSD-3-Clause
+#include "vtkOpenGLSurfaceProbeVolumeMapper.h"
+
+#include "vtkCommand.h"
+#include "vtkExecutive.h"
+#include "vtkImageData.h"
+#include "vtkInformation.h"
+#include "vtkObjectFactory.h"
+#include "vtkOpenGLFramebufferObject.h"
+#include "vtkOpenGLRenderWindow.h"
+#include "vtkOpenGLResourceFreeCallback.h"
+#include "vtkOpenGLState.h"
+#include "vtkOpenGLTexture.h"
+#include "vtkOpenGLVertexBufferObject.h"
+#include "vtkPointData.h"
+#include "vtkPolyData.h"
+#include "vtkRenderer.h"
+#include "vtkShaderProgram.h"
+#include "vtkShaderProperty.h"
+#include "vtkTextureObject.h"
+#include "vtkVolumeTexture.h"
+
+#include <vtk_glad.h>
+
+VTK_ABI_NAMESPACE_BEGIN
+vtkStandardNewMacro(vtkOpenGLSurfaceProbeVolumeMapper);
+
+//------------------------------------------------------------------------------
+vtkOpenGLSurfaceProbeVolumeMapper::vtkOpenGLSurfaceProbeVolumeMapper()
+{
+  this->SetNumberOfInputPorts(3);
+}
+
+//------------------------------------------------------------------------------
+int vtkOpenGLSurfaceProbeVolumeMapper::FillInputPortInformation(int port, vtkInformation* info)
+{
+  if (port == 0)
+  {
+    info->Set(vtkAlgorithm::INPUT_REQUIRED_DATA_TYPE(), "vtkPolyData");
+    return 1;
+  }
+  else if (port == 1)
+  {
+    info->Set(vtkAlgorithm::INPUT_REQUIRED_DATA_TYPE(), "vtkImageData");
+    return 1;
+  }
+  else if (port == 2)
+  {
+    info->Set(vtkAlgorithm::INPUT_IS_OPTIONAL(), 1);
+    info->Set(vtkAlgorithm::INPUT_REQUIRED_DATA_TYPE(), "vtkPolyData");
+    return 1;
+  }
+  return 0;
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::SetProbeInputData(vtkPolyData* in)
+{
+  this->SetInputDataObject(2, in);
+}
+
+//------------------------------------------------------------------------------
+vtkPolyData* vtkOpenGLSurfaceProbeVolumeMapper::GetProbeInput()
+{
+  if (this->GetNumberOfInputConnections(2) < 1)
+  {
+    return nullptr;
+  }
+
+  return vtkPolyData::SafeDownCast(this->GetExecutive()->GetInputData(2, 0));
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::SetProbeInputConnection(vtkAlgorithmOutput* algOutput)
+{
+  this->SetInputConnection(2, algOutput);
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::SetSourceData(vtkImageData* in)
+{
+  this->SetInputDataObject(1, in);
+}
+
+//------------------------------------------------------------------------------
+vtkImageData* vtkOpenGLSurfaceProbeVolumeMapper::GetSource()
+{
+  if (this->GetNumberOfInputConnections(1) < 1)
+  {
+    return nullptr;
+  }
+
+  return vtkImageData::SafeDownCast(this->GetExecutive()->GetInputData(1, 0));
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::SetSourceConnection(vtkAlgorithmOutput* algOutput)
+{
+  this->SetInputConnection(1, algOutput);
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::RenderPiece(vtkRenderer* ren, vtkActor* actor)
+{
+  // Make sure that we have been properly initialized.
+  if (ren->GetRenderWindow()->CheckAbortStatus())
+  {
+    return;
+  }
+
+  this->ResourceCallback->RegisterGraphicsResources(
+    static_cast<vtkOpenGLRenderWindow*>(ren->GetRenderWindow()));
+
+  // The second input is used for probing if it exists.
+  // The first input is always the one being rendered to avoid having to recompute bounds.
+  vtkPolyData* secondInput = vtkPolyData::SafeDownCast(this->GetExecutive()->GetInputData(2, 0));
+  if (secondInput)
+  {
+    this->CurrentInput = secondInput;
+  }
+  else
+  {
+    this->CurrentInput = this->GetInput();
+  }
+
+  // Source volume being probed
+  vtkImageData* sourceInput = vtkImageData::SafeDownCast(this->GetExecutive()->GetInputData(1, 0));
+
+  if (this->CurrentInput == nullptr || sourceInput == nullptr)
+  {
+    vtkErrorMacro(<< "No input or source!");
+    return;
+  }
+
+  this->InvokeEvent(vtkCommand::StartEvent, nullptr);
+  if (!this->Static)
+  {
+    this->GetInputAlgorithm()->Update();
+
+    // Update probed volume
+    this->GetInputAlgorithm(1, 0)->Update();
+
+    // Update probe surface
+    if (secondInput)
+    {
+      this->GetInputAlgorithm(2, 0)->Update();
+    }
+  }
+  this->InvokeEvent(vtkCommand::EndEvent, nullptr);
+
+  // if there are no points then we are done
+  if (!this->CurrentInput->GetPoints())
+  {
+    return;
+  }
+
+  this->UpdateCameraShiftScale(ren, actor);
+  this->RenderPieceStart(ren, actor);
+
+  // 1. Position texture pass
+  this->ReplaceShaderPositionPass(actor);
+
+  // Render positions and normals into FBO textures
+  this->ReplaceActiveFBO(ren);
+
+  this->RenderPieceDraw(ren, actor);
+
+  this->RestoreActiveFBO(ren);
+
+  // Clear position pass shader replacements
+  // WARNING: This has the side-effect of clearing the user's shader replacement.
+  // To prevent this we should use ClearVertexShaderReplacements/ClearFragmentShaderReplacements
+  // with the original strings used in ReplaceShaderPositionPass.
+  actor->GetShaderProperty()->ClearAllVertexShaderReplacements();
+  actor->GetShaderProperty()->ClearAllFragmentShaderReplacements();
+
+  // 2. Probe pass
+
+  // Replace input
+  this->CurrentInput = this->GetInput();
+
+  if (this->CurrentInput == nullptr)
+  {
+    vtkErrorMacro(<< "No input!");
+    return;
+  }
+
+  // if there are no points then we are done
+  if (!this->CurrentInput->GetPoints())
+  {
+    return;
+  }
+
+  this->RenderPieceStart(ren, actor);
+
+  this->ReplaceShaderProbePass(actor);
+
+  this->RenderPieceDraw(ren, actor);
+
+  // Deactivate textures used in probe pass
+  this->PositionsTextureObject->Deactivate();
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->NormalsTextureObject->Deactivate();
+  }
+  this->VolumeTexture->GetCurrentBlock()->TextureObject->Deactivate();
+
+  this->RenderPieceFinish(ren, actor);
+
+  // Clear probe pass shader replacements
+  // WARNING: This has the side-effect of clearing the user's shader replacement.
+  // To prevent this we should use ClearVertexShaderReplacements/ClearFragmentShaderReplacements
+  // with the original strings used in ReplaceShaderProbePass.
+  actor->GetShaderProperty()->ClearAllVertexShaderReplacements();
+  actor->GetShaderProperty()->ClearAllFragmentShaderReplacements();
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::UpdateShaders(
+  vtkOpenGLHelper& cellBO, vtkRenderer* ren, vtkActor* act)
+{
+  vtkOpenGLPolyDataMapper::UpdateShaders(cellBO, ren, act);
+
+  // Update uniforms according to the current pass
+  switch (this->CurrentPass)
+  {
+    case PassTypes::POSITION_TEXTURE:
+      // Handle VBO shift and scale only when the actor matrix is identity.
+      // Otherwise VBOShiftScale is already multiplied with the actor matrix in the base class.
+      if (act->GetIsIdentity() && !this->VBOShiftScale->IsIdentity())
+      {
+        cellBO.Program->SetUniformMatrix("MCWCMatrix", this->VBOShiftScale);
+      }
+      break;
+    case PassTypes::PROBE:
+      this->UpdateShadersProbePass(cellBO, ren);
+      break;
+    default:
+      break;
+  }
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::ReplaceShaderPositionPass(vtkActor* actor)
+{
+  // Position implementation.
+  // Expect TCoords in the [0, 1] range and normalize them to define the fragment position.
+  // The vertex position is passed to the fragment shader to be written in the texture.
+  std::string positionImpl =
+    "gl_Position = vec4(tcoord.x * 2.0 - 1.0, tcoord.y * 2.0 - 1.0, 0.0, 1.0);\n";
+
+  if (!this->VBOShiftScale->IsIdentity() || !actor->GetIsIdentity())
+  {
+    actor->GetShaderProperty()->AddVertexShaderReplacement("//VTK::PositionVC::Dec", true,
+      "//VTK::PositionVC::Dec\n"
+      "uniform mat4 MCWCMatrix;\n",
+      true);
+
+    positionImpl += "vertexVCVSOutput = MCWCMatrix * vertexMC;\n";
+  }
+  else
+  {
+    positionImpl += "vertexVCVSOutput = vertexMC;\n";
+  }
+
+  actor->GetShaderProperty()->AddVertexShaderReplacement(
+    "//VTK::PositionVC::Impl", true, positionImpl, true);
+
+  // TCoords attribute are always uploaded to the GPU when they exist in the superclass, but
+  // tcoord is only defined in the shader when the actor is textured. Force the declaration here.
+  actor->GetShaderProperty()->AddVertexShaderReplacement(
+    "//VTK::TCoord::Dec", false, "in vec2 tcoord;", true);
+
+  // Write vertex position in texture
+  // Override gl_FragData set in Light::Impl and TCoord::Impl
+  std::string tcoordsImpl = "gl_FragData[0] = vertexVCVSOutput;\n";
+
+  // Blending requires normals
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    // Pass normals from the vertex to the fragment shader
+    actor->GetShaderProperty()->AddVertexShaderReplacement(
+      "//VTK::Normal::Impl", true, "normalVCVSOutput = normalMC;\n", true);
+
+    // Write normals in additional render target
+    tcoordsImpl += "gl_FragData[1] = vec4(normalVCVSOutput, 0.0);\n";
+  }
+
+  actor->GetShaderProperty()->AddFragmentShaderReplacement(
+    "//VTK::TCoord::Impl", true, tcoordsImpl, true);
+
+  // Prevent OIT pass from overriding gl_FragData values
+  actor->GetShaderProperty()->AddFragmentShaderReplacement(
+    "//VTK::DepthPeeling::Impl", true, "", true);
+
+  // Switch to positions/normals pass to update shaders uniforms accordingly
+  this->CurrentPass = PassTypes::POSITION_TEXTURE;
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::ReplaceShaderProbePass(vtkActor* actor)
+{
+  // Pass texture coordinates from vertex shader to fragment shader
+  actor->GetShaderProperty()->AddVertexShaderReplacement("//VTK::TCoord::Dec", true,
+    "in vec2 tcoord;\n"
+    "out vec2 tcoordVCVSOutput;\n",
+    true);
+
+  actor->GetShaderProperty()->AddVertexShaderReplacement(
+    "//VTK::TCoord::Impl", true, "tcoordVCVSOutput = tcoord;\n", true);
+
+  // Textures and coloring declaration
+  std::string tmapDec = "//VTK::TMap::Dec\n" // keep default replacement
+                        "uniform sampler2D positionTexture;\n"
+                        "uniform sampler3D in_volume;\n"
+                        "uniform mat4 in_inverseTextureDatasetMatrix;\n"
+                        "uniform mat4 in_cellToPoint;\n"
+                        // Coloring
+                        "uniform vec4 in_volume_scale;\n"
+                        "uniform vec4 in_volume_bias;\n"
+                        "uniform float in_window;\n"
+                        "uniform float in_level;\n";
+
+  // Blending requires normals and blending uniforms
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    tmapDec += "uniform sampler2D normalTexture;\n"
+               "uniform vec3 in_volume_spacing;\n"
+               "uniform float blend_width;\n";
+  }
+
+  actor->GetShaderProperty()->AddFragmentShaderReplacement("//VTK::TMap::Dec", true, tmapDec, true);
+
+  actor->GetShaderProperty()->AddFragmentShaderReplacement("//VTK::TCoord::Dec", true,
+    "in vec2 tcoordVCVSOutput;\n"
+    // Window/Level declaration
+    "vec4 applyWindowLevel(vec4 color)\n"
+    "{\n"
+    "  float l = in_level; \n"
+    "  float w = in_window;\n"
+    "  float s = w > 0 ? 0.5 : -0.5;\n" // handle negative window
+    "  color = clamp(color, l - s * w, l + s * w);\n"
+    "  return (color - (l - 0.5 * w)) / w;\n"
+    "}\n",
+    true);
+
+  std::string tcoordsImpl =
+    // Get the current fragment position on the probe surface
+    "vec3 fragmentPos = texture2D(positionTexture, tcoordVCVSOutput).xyz;\n"
+    // Background value when sampling outside volume.
+    // WARNING: Initialization to 0 currently required for average blending
+    "vec4 volumeValue = vec4(0, 0, 0, 0);\n"
+    "int sampleCount = 0;\n"; // Keep track of the number of samples for blending
+
+  if (this->GetBlendMode() == BlendModes::NONE)
+  {
+    tcoordsImpl +=
+      "vec3 texPos = (in_cellToPoint * in_inverseTextureDatasetMatrix * vec4(fragmentPos.xyz, "
+      "1.0)).xyz;\n"
+      "if ((all(lessThanEqual(texPos, vec3(1.0))) &&\n"
+      "  all(greaterThanEqual(texPos, vec3(0.0)))))\n"
+      "{\n"
+      "  volumeValue = texture3D(in_volume, texPos)  * in_volume_scale[0] + in_volume_bias[0];\n"
+      "  sampleCount++;\n"
+      "}\n";
+  }
+  else // Blend modes
+  {
+    tcoordsImpl +=
+      "float epsilon = 1e-7;\n"
+      // Get the current fragment normal on the probe surface
+      "vec3 fragmentNormal = texture2D(normalTexture, tcoordVCVSOutput).xyz;\n"
+      "fragmentNormal = normalize(fragmentNormal);\n"
+      // Use the half of the minimum spacing values as sampling step.
+      "float spacing = 0.5 * min(min(in_volume_spacing[0], in_volume_spacing[1]), "
+      "in_volume_spacing[2]);\n"
+      "spacing = max(spacing, epsilon);\n" // Force positive spacing to avoid infinite loop below
+      "float offset = -0.5 * (blend_width + epsilon);\n"
+      "while(offset < 0.5 * (blend_width + epsilon))\n"
+      "{"
+      "  vec3 pos = fragmentPos + offset * fragmentNormal;\n"
+      "  vec3 texPos = (in_cellToPoint * in_inverseTextureDatasetMatrix * vec4(pos.xyz, "
+      "1.0)).xyz;\n"
+      "  if ((all(lessThanEqual(texPos, vec3(1.0))) && \n"
+      "    all(greaterThanEqual(texPos, vec3(0.0)))))\n"
+      "  {\n"
+      "    vec4 currentColor = texture3D(in_volume, texPos) * in_volume_scale[0] + "
+      "in_volume_bias[0];\n";
+    switch (this->GetBlendMode())
+    {
+      case BlendModes::MAX:
+        tcoordsImpl +=
+          "    volumeValue.r = max(currentColor.r, sampleCount > 0 ? volumeValue.r : 0.0);\n";
+        break;
+      case BlendModes::MIN:
+        tcoordsImpl +=
+          "    volumeValue.r = min(currentColor.r, sampleCount > 0 ? volumeValue.r : 1.0);\n";
+        break;
+      case BlendModes::AVERAGE:
+        tcoordsImpl += "    volumeValue += currentColor;\n";
+        break;
+      default:
+        break;
+    }
+    tcoordsImpl += "    sampleCount++;\n"
+                   "  }"
+                   "  offset += spacing;"
+                   "}";
+  }
+
+  if (this->GetBlendMode() == BlendModes::AVERAGE)
+  {
+    tcoordsImpl += "if (sampleCount > 0)\n"
+                   "{\n"
+                   "  volumeValue = volumeValue / sampleCount;\n"
+                   "}\n";
+  }
+
+  // Compute final color
+  tcoordsImpl +=
+    "if (sampleCount > 0)\n"
+    "{\n"
+    "  volumeValue = applyWindowLevel(volumeValue);"
+    "  volumeValue.a = opacityUniform;"
+    "}\n"
+    // Only support grayscale volumes.
+    "gl_FragData[0] = vec4(volumeValue.r, volumeValue.r, volumeValue.r, volumeValue.a);\n";
+
+  actor->GetShaderProperty()->AddFragmentShaderReplacement(
+    "//VTK::TCoord::Impl", true, tcoordsImpl, true);
+
+  // Switch to probing pass to update shaders uniforms accordingly
+  this->CurrentPass = PassTypes::PROBE;
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::UpdateShadersProbePass(
+  vtkOpenGLHelper& cellBO, vtkRenderer* ren)
+{
+  if (!this->VolumeTexture->GetLoadedScalars())
+  {
+    vtkImageData* sourceInput = this->GetSource();
+
+    // The extent of the volume must start at (0,0,0)
+    // see "void vtkGPUVolumeRayCastMapper::TransformInput"
+    this->TransformedSource->ShallowCopy(sourceInput);
+    // Get the current extents.
+    int extents[6];
+    this->TransformedSource->GetExtent(extents);
+
+    // Get the current origin and spacing.
+    double origin[3], spacing[3];
+    this->TransformedSource->GetOrigin(origin);
+    this->TransformedSource->GetSpacing(spacing);
+
+    for (int cc = 0; cc < 3; cc++)
+    {
+      // Transform the origin and the extents.
+      origin[cc] = origin[cc] + extents[2 * cc] * spacing[cc];
+      extents[2 * cc + 1] -= extents[2 * cc];
+      extents[2 * cc] = 0;
+    }
+
+    this->TransformedSource->SetOrigin(origin);
+    this->TransformedSource->SetExtent(extents);
+
+    // Always use scalar point data.
+    // Mimic vtkAbstractVolumeMapper::GetScalars to handle array access and cell scalars if needed.
+    vtkDataArray* scalars = this->TransformedSource->GetPointData()->GetScalars();
+    int isCellData = 0;
+
+    // Load volume
+    this->VolumeTexture->LoadVolume(
+      ren, this->TransformedSource, scalars, isCellData, VTK_LINEAR_INTERPOLATION);
+  }
+
+  std::vector<float> VolMatVec = {};
+  std::vector<float> InvTexMatVec = {};
+  std::vector<float> CellToPointVec = {};
+  VolMatVec.resize(16, 0);
+  InvTexMatVec.resize(16, 0);
+  CellToPointVec.resize(16, 0);
+
+  vtkNew<vtkMatrix4x4> texToDataMat;
+  texToDataMat->DeepCopy(this->VolumeTexture->GetCurrentBlock()->TextureToDataset.GetPointer());
+  texToDataMat->Transpose();
+
+  texToDataMat->Invert();
+
+  vtkNew<vtkMatrix4x4> cellToPointMat;
+  cellToPointMat->DeepCopy(this->VolumeTexture->CellToPointMatrix.GetPointer());
+  cellToPointMat->Transpose();
+
+  for (int i = 0; i < 16; i++)
+  {
+    InvTexMatVec[i] = texToDataMat->Element[i / 4][i % 4];
+    CellToPointVec[i] = cellToPointMat->Element[i / 4][i % 4];
+  }
+
+  cellBO.Program->SetUniformMatrix4x4("in_inverseTextureDatasetMatrix", InvTexMatVec.data());
+  cellBO.Program->SetUniformMatrix4x4("in_cellToPoint", CellToPointVec.data());
+
+  cellBO.Program->SetUniform4f("in_volume_scale", this->VolumeTexture->Scale);
+  cellBO.Program->SetUniform4f("in_volume_bias", this->VolumeTexture->Bias);
+
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    cellBO.Program->SetUniform3f("in_volume_spacing", this->TransformedSource->GetSpacing());
+    cellBO.Program->SetUniformf("blend_width", this->GetBlendWidth());
+  }
+
+  // Rescale window/level.
+  float scalarRange[2] = { this->VolumeTexture->ScalarRange[0][0],
+    this->VolumeTexture->ScalarRange[0][1] };
+  double finalWindow = this->Window / (scalarRange[1] - scalarRange[0]);
+  double finalLevel = (this->Level - scalarRange[0]) / (scalarRange[1] - scalarRange[0]);
+  cellBO.Program->SetUniformf("in_window", finalWindow);
+  cellBO.Program->SetUniformf("in_level", finalLevel);
+
+  // Handle single block.
+  vtkTextureObject* volumeTextureObject = this->VolumeTexture->GetCurrentBlock()->TextureObject;
+  volumeTextureObject->Activate();
+  cellBO.Program->SetUniformi("in_volume", volumeTextureObject->GetTextureUnit());
+
+  this->PositionsTextureObject->Activate();
+  cellBO.Program->SetUniformi("positionTexture", this->PositionsTextureObject->GetTextureUnit());
+
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->NormalsTextureObject->Activate();
+    cellBO.Program->SetUniformi("normalTexture", this->NormalsTextureObject->GetTextureUnit());
+  }
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::CreateTexture(
+  vtkTextureObject* texture, vtkOpenGLRenderWindow* renWin)
+{
+  if (texture->GetHandle() == 0)
+  {
+    texture->SetContext(renWin);
+    texture->SetFormat(GL_RGBA);
+    texture->SetInternalFormat(GL_RGBA16F);
+    texture->SetDataType(GL_FLOAT);
+    texture->SetWrapS(vtkTextureObject::ClampToEdge);
+    texture->SetWrapT(vtkTextureObject::ClampToEdge);
+    texture->SetMinificationFilter(vtkTextureObject::Linear);
+    texture->SetMagnificationFilter(vtkTextureObject::Linear);
+    texture->Allocate2D(renWin->GetSize()[0], renWin->GetSize()[1], 4, VTK_FLOAT);
+  }
+  else
+  {
+    texture->Resize(renWin->GetSize()[0], renWin->GetSize()[1]);
+  }
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::ReplaceActiveFBO(vtkRenderer* ren)
+{
+  vtkOpenGLRenderWindow* renWin = vtkOpenGLRenderWindow::SafeDownCast(ren->GetRenderWindow());
+  if (!renWin)
+  {
+    return;
+  }
+
+  // Save viewport. It must be queried from the current state as it might not
+  // match vtkRenderer::GetTiledSizeAndOrigin when using the OIT render pass.
+  renWin->GetState()->vtkglGetIntegerv(GL_VIEWPORT, this->SavedViewport);
+  // Save scissor test and blend state
+  this->SavedScissorTestState = renWin->GetState()->GetEnumState(GL_SCISSOR_TEST);
+  this->SavedBlendState = renWin->GetState()->GetEnumState(GL_BLEND);
+
+  // Use the entire render window to render textures, even when having multiple renderers
+  renWin->GetState()->vtkglViewport(0, 0, renWin->GetSize()[0], renWin->GetSize()[1]);
+  renWin->GetState()->vtkglDisable(GL_SCISSOR_TEST);
+  renWin->GetState()->vtkglDisable(GL_BLEND);
+
+  this->CreateTexture(this->PositionsTextureObject, renWin);
+  this->PositionsTextureObject->Activate();
+
+  // Blending requires normals
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->CreateTexture(this->NormalsTextureObject, renWin);
+    this->NormalsTextureObject->Activate();
+  }
+
+  renWin->GetState()->PushFramebufferBindings();
+
+  this->FBO->SetContext(renWin);
+  this->FBO->Bind(GL_FRAMEBUFFER);
+  this->FBO->AddColorAttachment(0U, this->PositionsTextureObject);
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->FBO->AddColorAttachment(1U, this->NormalsTextureObject);
+  }
+  this->FBO->ActivateDrawBuffers(this->GetBlendMode() != BlendModes::NONE ? 2 : 1);
+  this->FBO->CheckFrameBufferStatus(GL_FRAMEBUFFER);
+  this->FBO->GetContext()->GetState()->vtkglClearColor(0.0, 0.0, 0.0, 0.0);
+  this->FBO->GetContext()->GetState()->vtkglClear(GL_COLOR_BUFFER_BIT);
+}
+
+//------------------------------------------------------------------------------
+void vtkOpenGLSurfaceProbeVolumeMapper::RestoreActiveFBO(vtkRenderer* ren)
+{
+  vtkOpenGLRenderWindow* renWin = vtkOpenGLRenderWindow::SafeDownCast(ren->GetRenderWindow());
+  if (!renWin)
+  {
+    return;
+  }
+
+  this->FBO->RemoveColorAttachment(0U);
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->FBO->RemoveColorAttachment(1U);
+  }
+
+  this->FBO->DeactivateDrawBuffers();
+  this->FBO->GetContext()->GetState()->PopFramebufferBindings();
+
+  this->PositionsTextureObject->Deactivate();
+  if (this->GetBlendMode() != BlendModes::NONE)
+  {
+    this->NormalsTextureObject->Deactivate();
+  }
+
+  // Restore scissor test, blend and viewport state
+  this->SavedScissorTestState ? renWin->GetState()->vtkglEnable(GL_SCISSOR_TEST)
+                              : renWin->GetState()->vtkglDisable(GL_SCISSOR_TEST);
+  this->SavedBlendState ? renWin->GetState()->vtkglEnable(GL_BLEND)
+                        : renWin->GetState()->vtkglDisable(GL_BLEND);
+  renWin->GetState()->vtkglViewport(
+    this->SavedViewport[0], this->SavedViewport[1], this->SavedViewport[2], this->SavedViewport[3]);
+}
+VTK_ABI_NAMESPACE_END
diff --git a/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.h b/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.h
new file mode 100644
index 0000000000000000000000000000000000000000..041281ab31a5ed089e47d1e0dc450ad6cd57d116
--- /dev/null
+++ b/Rendering/VolumeOpenGL2/vtkOpenGLSurfaceProbeVolumeMapper.h
@@ -0,0 +1,174 @@
+// SPDX-FileCopyrightText: Copyright (c) Ken Martin, Will Schroeder, Bill Lorensen
+// SPDX-License-Identifier: BSD-3-Clause
+/**
+ * @class   vtkOpenGLSurfaceProbeVolumeMapper
+ * @brief   PolyDataMapper colored with probed volume data.
+ *
+ * PolyDataMapper that probes volume data at the points positions specified in its input data.
+ * The rendered surface is colored using the scalar values that were probed in the source volume.
+ * The mapper accepts three inputs: the Input, the Source and an optional ProbeInput.
+ * The Source data defines the vtkImageData from which scalar values are interpolated.
+ * The Input data defines the rendered surface.
+ * The ProbeInput defines the geometry used to interpolate the source data.
+ * If the ProbeInput is not specified, the Input is used both for probing and rendering.
+ *
+ * Projecting the scalar values from the ProbeInput to the Input is done thanks to texture
+ * coordinates. Both inputs must provide texture coordinates in the [0, 1] range.
+ *
+ * The sampled scalar values can be computed with different blending strategy that use surface
+ * normals to perform thick probing of the Source data.
+ *
+ * @note The following features are not supported yet but should be considered in the future.
+ *
+ * - The volume texture is always uploaded using linear interpolation.
+ *   The public API could provide a setter to use nearest neighbor interpolation instead.
+ *
+ * - If the source is rendered by a volume mapper, any transform applied to the volume
+ *   is ignored as there is no interface to pass this information.
+ *
+ * - Only the first scalar component is used for rendering and rescaled with Window/Level.
+ *   Consider supporting RGB volumes without W/L mapping, and independent component.
+ *   Consider supporting Color and opacity transfer function to replace W/L mapping.
+ *
+ * Passing a vtkVolumeProperty to this mapper should be considered to address the above points.
+ *
+ * - A background value of (0, 0, 0, 0) is used when probing outside the volume, but this
+ *   parameters could be exposed in the public API
+ *
+ * - A step value corresponding to the half of the minimum spacing value of the source is used for
+ *   blend modes, but it could be configured from the public API.
+ */
+
+#ifndef vtkOpenGLSurfaceProbeVolumeMapper_h
+#define vtkOpenGLSurfaceProbeVolumeMapper_h
+
+#include "vtkNew.h" // For vtkNew
+#include "vtkOpenGLPolyDataMapper.h"
+#include "vtkRenderingVolumeOpenGL2Module.h" // For export macro
+
+VTK_ABI_NAMESPACE_BEGIN
+class vtkOpenGLFramebufferObject;
+class vtkOpenGLRenderWindow;
+class vtkTextureObject;
+class vtkVolumeTexture;
+
+class VTKRENDERINGVOLUMEOPENGL2_EXPORT vtkOpenGLSurfaceProbeVolumeMapper
+  : public vtkOpenGLPolyDataMapper
+{
+public:
+  static vtkOpenGLSurfaceProbeVolumeMapper* New();
+  vtkTypeMacro(vtkOpenGLSurfaceProbeVolumeMapper, vtkOpenGLPolyDataMapper);
+
+  ///@{
+  /**
+   * Specify the input data used for probing (optional).
+   * If no probe data is specified, the input is used.
+   */
+  void SetProbeInputData(vtkPolyData* in);
+  vtkPolyData* GetProbeInput();
+  void SetProbeInputConnection(vtkAlgorithmOutput* algOutput);
+  ///@}
+
+  ///@{
+  /**
+   * Specify the input data to be probed.
+   */
+  void SetSourceData(vtkImageData* in);
+  vtkImageData* GetSource();
+  void SetSourceConnection(vtkAlgorithmOutput* algOutput);
+  ///@}
+
+  ///@{
+  /**
+   * Set/Get the current window and level values used for scalar coloring.
+   */
+  vtkGetMacro(Window, double);
+  vtkSetMacro(Window, double);
+
+  vtkGetMacro(Level, double);
+  vtkSetMacro(Level, double);
+  ///@}
+
+  ///@{
+  /**
+   * Set/Get the blend mode.
+   * Default is none.
+   *
+   * \sa vtkVolumeMapper::GetBlendMode()
+   */
+  enum class BlendModes : unsigned int
+  {
+    NONE = 0,
+    MAX,
+    MIN,
+    AVERAGE
+  };
+
+  vtkGetEnumMacro(BlendMode, BlendModes);
+  vtkSetEnumMacro(BlendMode, BlendModes);
+  void SetBlendModeToNone() { this->SetBlendMode(BlendModes::NONE); }
+  void SetBlendModeToMaximumIntensity() { this->SetBlendMode(BlendModes::MAX); }
+  void SetBlendModeToMinimumIntensity() { this->SetBlendMode(BlendModes::MIN); }
+  void SetBlendModeToAverageIntensity() { this->SetBlendMode(BlendModes::AVERAGE); }
+  ///@}
+
+  ///@{
+  /**
+   * Set/Get the blend width in world coordinates.
+   */
+  vtkGetMacro(BlendWidth, double);
+  vtkSetMacro(BlendWidth, double);
+  ///@}
+
+  void RenderPiece(vtkRenderer* ren, vtkActor* act) override;
+
+  void UpdateShaders(vtkOpenGLHelper& cellBO, vtkRenderer* ren, vtkActor* act) override;
+
+protected:
+  int FillInputPortInformation(int port, vtkInformation* info) override;
+
+  virtual void ReplaceShaderPositionPass(vtkActor* act);
+  virtual void ReplaceShaderProbePass(vtkActor* act);
+  virtual void UpdateShadersProbePass(vtkOpenGLHelper& cellBO, vtkRenderer* ren);
+
+private:
+  vtkOpenGLSurfaceProbeVolumeMapper();
+  ~vtkOpenGLSurfaceProbeVolumeMapper() override = default;
+
+  void CreateTexture(vtkTextureObject*, vtkOpenGLRenderWindow*);
+  void ReplaceActiveFBO(vtkRenderer*);
+  void RestoreActiveFBO(vtkRenderer*);
+
+  vtkNew<vtkOpenGLFramebufferObject> FBO;
+
+  vtkNew<vtkTextureObject> PositionsTextureObject;
+  vtkNew<vtkTextureObject> NormalsTextureObject;
+  vtkNew<vtkVolumeTexture> VolumeTexture;
+
+  vtkNew<vtkImageData> TransformedSource;
+
+  // Internal pass type used for shader updates
+  enum class PassTypes : unsigned int
+  {
+    DEFAULT = 0,
+    POSITION_TEXTURE,
+    PROBE
+  };
+  PassTypes CurrentPass = PassTypes::DEFAULT;
+
+  // Window / level
+  double Window = 1.0;
+  double Level = 0.0;
+
+  // Blend mode
+  BlendModes BlendMode = BlendModes::NONE;
+  double BlendWidth = 1.0;
+
+  // Saved state
+  bool SavedScissorTestState = false;
+  bool SavedBlendState = false;
+  int SavedViewport[4] = {};
+};
+
+VTK_ABI_NAMESPACE_END
+#endif