[Volume Rendering] Incorrect Depth Occlusion Between Two Intersecting Spheres in VTK 9.1.0

[Note: English is not my first language, so please let me know if anything is unclear.]
I’m working on a scientific visualization project using VTK 9.1.0 with C++, where I need to render two intersecting, completely opaque spheres (one red, one blue). I’m facing significant challenges with achieving physically correct depth occlusion.

Current Problem:
The spheres don’t occlude each other based on their actual geometric relationships. Additionally, the occlusion pattern remains static when I rotate the camera, which breaks the 3D perception.

What Should Happen:

  • The sphere (or part of a sphere) that’s geometrically closer to the camera should appear in front
  • This occlusion relationship should update dynamically as I rotate the viewpoint

What I’ve Tried:

  1. vtkMultiBlockVolumeMapper: I combined both spheres into a multi-block dataset, but this didn’t improve depth ordering.
  2. Depth Peeling: Enabled via renderer->SetUseDepthPeeling(1), but this technique appears ineffective for volume rendering scenarios.

Minimal Code Example:

#include <vtkActor.h>
#include <vtkCamera.h>
#include <vtkColorTransferFunction.h>
#include <vtkImageData.h>
#include <vtkInteractorStyleTrackballCamera.h>
#include <vtkNew.h>
#include <vtkNamedColors.h>
#include <vtkPiecewiseFunction.h>
#include <vtkRenderWindow.h>
#include <vtkRenderWindowInteractor.h>
#include <vtkRenderer.h>
#include <vtkVolume.h>
#include <vtkVolumeProperty.h>
#include <vtkOpenGLGPUVolumeRayCastMapper.h>
#include <vtkImageMathematics.h>
#include <vtkImageBlend.h>

#include <iostream>
#include <cmath>

int main(int argc, char* argv[])
{
    vtkNew<vtkNamedColors> colors;
    vtkNew<vtkRenderer> renderer;
    renderer->SetBackground(colors->GetColor3d("Black").GetData());
    vtkNew<vtkRenderWindow> renderWindow;
    renderWindow->AddRenderer(renderer);
    renderWindow->SetSize(800, 600);
    renderWindow->SetWindowName("Two Solid Interpenetrating Spheres");
    renderWindow->SetMultiSamples(8);

    vtkNew<vtkRenderWindowInteractor> renderWindowInteractor;
    renderWindowInteractor->SetRenderWindow(renderWindow);
    vtkNew<vtkInteractorStyleTrackballCamera> style;
    renderWindowInteractor->SetInteractorStyle(style);

    const double sphereRadius = 1.0;
    const int volumeSize = 128;
    const double spacing = 0.05;
    double center1[3] = { 0.0, 0.0, 0.0 };
    double center2[3] = { sphereRadius * 0.6, sphereRadius * 0.1, 0.0 };

    vtkNew<vtkImageData> sphere1Volume;
    sphere1Volume->SetDimensions(volumeSize, volumeSize, volumeSize);
    sphere1Volume->SetSpacing(spacing, spacing, spacing);
    sphere1Volume->SetOrigin(
        center1[0] - sphereRadius * 1.2,
        center1[1] - sphereRadius * 1.2,
        center1[2] - sphereRadius * 1.2
    );
    sphere1Volume->AllocateScalars(VTK_UNSIGNED_CHAR, 1);

    vtkNew<vtkImageData> sphere2Volume;
    sphere2Volume->SetDimensions(volumeSize, volumeSize, volumeSize);
    sphere2Volume->SetSpacing(spacing, spacing, spacing);
    sphere2Volume->SetOrigin(
        center2[0] - sphereRadius * 1.2, 
        center2[1] - sphereRadius * 1.2,
        center2[2] - sphereRadius * 1.2
    );
    sphere2Volume->AllocateScalars(VTK_UNSIGNED_CHAR, 1);

    unsigned char* sphere1Data = static_cast<unsigned char*>(sphere1Volume->GetScalarPointer());
    unsigned char* sphere2Data = static_cast<unsigned char*>(sphere2Volume->GetScalarPointer());
    for (int z = 0; z < volumeSize; z++) {
        double worldZ = sphere1Volume->GetOrigin()[2] + z * spacing;
        for (int y = 0; y < volumeSize; y++) {
            double worldY = sphere1Volume->GetOrigin()[1] + y * spacing;
            for (int x = 0; x < volumeSize; x++) {
                double worldX = sphere1Volume->GetOrigin()[0] + x * spacing;
                int idx = z * volumeSize * volumeSize + y * volumeSize + x;

                double dx1 = worldX - center1[0];
                double dy1 = worldY - center1[1];
                double dz1 = worldZ - center1[2];
                double dist1 = sqrt(dx1 * dx1 + dy1 * dy1 + dz1 * dz1);
                double dx2 = worldX - center2[0];
                double dy2 = worldY - center2[1];
                double dz2 = worldZ - center2[2];
                double dist2 = sqrt(dx2 * dx2 + dy2 * dy2 + dz2 * dz2);

                if (dist1 <= sphereRadius) {
                    sphere1Data[idx] = 255;
                }
                else {
                    sphere1Data[idx] = 0;
                }
                if (dist2 <= sphereRadius) {
                    sphere2Data[idx] = 255;
                }
                else {
                    sphere2Data[idx] = 0;
                }
            }
        }
    }

    vtkNew<vtkOpenGLGPUVolumeRayCastMapper> volumeMapper1;
    volumeMapper1->SetInputData(sphere1Volume);
    volumeMapper1->SetSampleDistance(0.01);
    volumeMapper1->AutoAdjustSampleDistancesOff();

    vtkNew<vtkOpenGLGPUVolumeRayCastMapper> volumeMapper2;
    volumeMapper2->SetInputData(sphere2Volume);
    volumeMapper2->SetSampleDistance(0.01);
    volumeMapper2->AutoAdjustSampleDistancesOff();

    vtkNew<vtkColorTransferFunction> colorTransferFunction1;
    colorTransferFunction1->AddRGBPoint(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
    colorTransferFunction1->AddRGBPoint(1.0, 0.7, 0.0, 0.0, 0.0, 0.0);
    colorTransferFunction1->AddRGBPoint(255.0, 1.0, 0.0, 0.0, 1.0, 1.0);
    vtkNew<vtkPiecewiseFunction> opacityTransferFunction1;
    opacityTransferFunction1->AddPoint(0.0, 0.0);
    opacityTransferFunction1->AddPoint(1.0, 0.0);
    opacityTransferFunction1->AddPoint(255.0, 1.0);

    vtkNew<vtkColorTransferFunction> colorTransferFunction2;
    colorTransferFunction2->AddRGBPoint(0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
    colorTransferFunction2->AddRGBPoint(1.0, 0.0, 0.0, 0.7, 0.0, 0.0); 
    colorTransferFunction2->AddRGBPoint(255.0, 0.0, 0.0, 1.0, 1.0, 1.0);
    vtkNew<vtkPiecewiseFunction> opacityTransferFunction2;
    opacityTransferFunction2->AddPoint(0.0, 0.0);
    opacityTransferFunction2->AddPoint(1.0, 0.0);
    opacityTransferFunction2->AddPoint(255.0, 1.0);

    vtkNew<vtkVolumeProperty> volumeProperty1;
    volumeProperty1->SetColor(colorTransferFunction1);
    volumeProperty1->SetScalarOpacity(opacityTransferFunction1);
    volumeProperty1->SetInterpolationTypeToLinear();
    volumeProperty1->ShadeOn(); 
    volumeProperty1->SetAmbient(0.5);
    volumeProperty1->SetDiffuse(0.8);
    volumeProperty1->SetSpecular(0.5);
    volumeProperty1->SetSpecularPower(20.0);

    vtkNew<vtkVolumeProperty> volumeProperty2;
    volumeProperty2->SetColor(colorTransferFunction2);
    volumeProperty2->SetScalarOpacity(opacityTransferFunction2);
    volumeProperty2->SetInterpolationTypeToLinear();
    volumeProperty2->ShadeOn();
    volumeProperty2->SetAmbient(0.5);
    volumeProperty2->SetDiffuse(0.8);
    volumeProperty2->SetSpecular(0.5);
    volumeProperty2->SetSpecularPower(20.0);

    vtkNew<vtkVolume> volume1;
    volume1->SetMapper(volumeMapper1);
    volume1->SetProperty(volumeProperty1);
    vtkNew<vtkVolume> volume2;
    volume2->SetMapper(volumeMapper2);
    volume2->SetProperty(volumeProperty2);
    renderer->AddVolume(volume1);
    renderer->AddVolume(volume2);

    vtkCamera* camera = renderer->GetActiveCamera();
    camera->SetPosition(4, 3, 4);
    camera->SetFocalPoint(0.3, 0.05, 0);
    camera->SetViewUp(0, 0, 1); 
    camera->Azimuth(30); 
    camera->Elevation(20);
    renderer->ResetCamera();

    renderWindow->Render();
    renderWindowInteractor->Start();
    return 0;
}

Incorrect occlusion screenshots:
The blue sphere always occludes the red sphere, regardless of viewpoint.

Thank you for any guidance!

Since the two volumes are overlapping in 3D space, please use vtkMultiVolume to allow depth sorting while ray-casting.