- Qt 6.8
- Qt Quick 3D
- Qt Quick 3D Examples and Tutorials
- Qt Quick 3D - Volumetric Rendering Example
- Qt Quick 3D | Commercial or GPLv3

# Qt Quick 3D - Volumetric Rendering Example

Demonstrates how to do volumetric rendering in Qt Quick 3D.

#### Introduction

This example demonstrates how to do volumetric rendering using a custom shader and a 3D volume texture with a technique called Volume ray casting. This example is an application that can read raw volume files and render them while being able to interactively modify various rendering settings such as the colormap, alpha and slice planes used. It is designed to work well with the volumes hosted at https://klacansky.com/open-scivis-datasets/ and automatically set the correct dimensions and scaling.

#### Implementation

The application is using QML and is a ApplicationWindow with a View3D containing the volume and a ScrollView containing the settings. To render our volume we create a scene in our View3D object with just a cube model in the middle.

     Model {
         id: cubeModel
         source: "#Cube"
         visible: true
         materials: CustomMaterial {
             shadingMode: CustomMaterial.Unshaded
             vertexShader: "alpha_blending.vert"
             fragmentShader: "alpha_blending.frag"

             property TextureInput volume: TextureInput {
                 texture: Texture {
                     textureData: VolumeTextureData {
                         id: volumeTextureData
                         source: "file:///default_colormap"
                         dataType: dataTypeComboBox.currentText ? dataTypeComboBox.currentText : "uint8"
                         width: parseInt(dataWidth.text)
                         height: parseInt(dataHeight.text)
                         depth: parseInt(dataDepth.text)
                     }
                     minFilter: Texture.Nearest
                     mipFilter: Texture.None
                     magFilter: Texture.Nearest
                     tilingModeHorizontal: Texture.ClampToEdge
                     tilingModeVertical: Texture.ClampToEdge
                     //tilingModeDepth: Texture.ClampToEdge // Qt 6.7
                 }
             }

             property TextureInput colormap: TextureInput {
                 enabled: true
                 texture: Texture {
                     id: colormapTexture
                     tilingModeHorizontal: Texture.ClampToEdge
                     source: getColormapSource(colormapCombo.currentIndex)
                 }
             }
             property real stepLength: Math.max(0.0001, parseFloat(
                                                    stepLengthText.text,
                                                    1 / cubeModel.maxSide))
             property real minSide: 1 / cubeModel.minSide
             property real stepAlpha: stepAlphaSlider.value
             property bool multipliedAlpha: multipliedAlphaBox.checked

             property real tMin: tSlider.first.value
             property real tMax: tSlider.second.value
             property vector3d sliceMin: sliceSliderMin(
                                             xSliceSlider.value,
                                             xSliceWidthSlider.value,
                                             ySliceSlider.value,
                                             ySliceWidthSlider.value,
                                             zSliceSlider.value,
                                             zSliceWidthSlider.value)
             property vector3d sliceMax: sliceSliderMax(
                                             xSliceSlider.value,
                                             xSliceWidthSlider.value,
                                             ySliceSlider.value,
                                             ySliceWidthSlider.value,
                                             zSliceSlider.value,
                                             zSliceWidthSlider.value)

             sourceBlend: CustomMaterial.SrcAlpha
             destinationBlend: CustomMaterial.OneMinusSrcAlpha
         }
         property real maxSide: Math.max(parseInt(dataWidth.text),
                                         parseInt(dataHeight.text),
                                         parseInt(dataDepth.text))
         property real minSide: Math.min(parseInt(dataWidth.text),
                                         parseInt(dataHeight.text),
                                         parseInt(dataDepth.text))
         scale: Qt.vector3d(parseFloat(scaleWidth.text),
                            parseFloat(scaleHeight.text),
                            parseFloat(scaleDepth.text))

         Model {
             visible: drawBoundingBox.checked
             geometry: LineBoxGeometry {}
             materials: DefaultMaterial {
                 diffuseColor: "#323232"
                 lighting: DefaultMaterial.NoLighting
             }
             receivesShadows: false
             castsShadows: false
         }

         Model {
             visible: drawBoundingBox.checked
             geometry: LineBoxGeometry {}
             materials: DefaultMaterial {
                 diffuseColor: "#323232"
                 lighting: DefaultMaterial.NoLighting
             }
             receivesShadows: false
             castsShadows: false
             position: sliceBoxPosition(xSliceSlider.value,
                                        ySliceSlider.value,
                                        zSliceSlider.value,
                                        xSliceWidthSlider.value,
                                        ySliceWidthSlider.value,
                                        zSliceWidthSlider.value)
             scale: Qt.vector3d(xSliceWidthSlider.value,
                                ySliceWidthSlider.value,
                                zSliceWidthSlider.value)
         }
     }

This cube is using a custom shader with a 3D texture for the volume and an image texture for the colormap. There are also various properties for the transfer function, slice planes etc. The volume texture's textureData is a custom QML type called `VolumeTextureData` and is defined in `volumetexturedata.cpp` and `volumetexturedata.h`.

     property TextureInput volume: TextureInput {
         texture: Texture {
             textureData: VolumeTextureData {
                 id: volumeTextureData
                 source: "file:///default_colormap"
                 dataType: dataTypeComboBox.currentText ? dataTypeComboBox.currentText : "uint8"
                 width: parseInt(dataWidth.text)
                 height: parseInt(dataHeight.text)
                 depth: parseInt(dataDepth.text)
             }
             minFilter: Texture.Nearest
             mipFilter: Texture.None
             magFilter: Texture.Nearest
             tilingModeHorizontal: Texture.ClampToEdge
             tilingModeVertical: Texture.ClampToEdge
             //tilingModeDepth: Texture.ClampToEdge // Qt 6.7
         }
     }

It contains the options `source`, `dataType`, `width`, `height` and `depth` that define how the raw volume file should be interpreted. `VolumeTextureData` also contains the function `loadAsync` for asynchronously loading a volume. It will send either a `loadSucceeded` or a `loadFailed` signal.

This cube model also contains two models containing a `LineBoxGeometry`. These are boxes showing the bounding box of the volume and the slice planes.

     Model {
         visible: drawBoundingBox.checked
         geometry: LineBoxGeometry {}
         materials: DefaultMaterial {
             diffuseColor: "#323232"
             lighting: DefaultMaterial.NoLighting
         }
         receivesShadows: false
         castsShadows: false
     }

     Model {
         visible: drawBoundingBox.checked
         geometry: LineBoxGeometry {}
         materials: DefaultMaterial {
             diffuseColor: "#323232"
             lighting: DefaultMaterial.NoLighting
         }
         receivesShadows: false
         castsShadows: false
         position: sliceBoxPosition(xSliceSlider.value,
                                    ySliceSlider.value,
                                    zSliceSlider.value,
                                    xSliceWidthSlider.value,
                                    ySliceWidthSlider.value,
                                    zSliceWidthSlider.value)
         scale: Qt.vector3d(xSliceWidthSlider.value,
                            ySliceWidthSlider.value,
                            zSliceWidthSlider.value)
     }

Let's have a look at the shaders. The vertex shader is very simple and will, aside from the MVP projection of the position, calculate the direction of the ray from the camera to the model in model space:

     void MAIN()
     {
         POSITION = MODELVIEWPROJECTION_MATRIX * vec4(VERTEX, 1.0);
         ray_direction_model = VERTEX - (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION, 1.0)).xyz;
     }

The fragment shader will start with calculating where our ray-marching ray will start in model space taking into account the slice planes. The `while` loop will step along the ray, sampling the voxels at equal distance adding the color and opactiy for the voxel's value in the colormap.

     void MAIN()
     {
         FRAGCOLOR = vec4(0);

         // The camera position (eye) in model space
         const vec3 ray_origin_model = (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION, 1)).xyz;

         // Get the ray intersection with the sliced box
         float t_0, t_1;
         const vec3 top_sliced = vec3(100)*sliceMax - vec3(50);
         const vec3 bottom_sliced = vec3(100)*sliceMin - vec3(50);
         if (!ray_box_intersection(ray_origin_model, ray_direction_model, bottom_sliced, top_sliced, t_0, t_1))
             return; // No ray intersection with sliced box, nothing to render

         // Get the start/end points of the ray in original box
         const vec3 top = vec3(50, 50, 50);
         const vec3 bottom = vec3(-50, -50, -50);
         const vec3 ray_start = (ray_origin_model + ray_direction_model * t_0 - bottom) / (top - bottom);
         const vec3 ray_stop =  (ray_origin_model + ray_direction_model * t_1 - bottom) / (top - bottom);

         vec3 ray = ray_stop - ray_start;
         float ray_length = length(ray);
         vec3 step_vector = stepLength * ray / ray_length;

         vec3 position = ray_start;

         // Ray march until reaching the end of the volume, or color saturation
         while (ray_length > 0) {
             ray_length -= stepLength;
             position += step_vector;

             float val = textureLod(volume, position, 0).r;
             if (val == 0 || val < tMin || val > tMax)
                 continue;

             const float alpha = multipliedAlpha ? val * stepAlpha : stepAlpha;
             vec4 val_color = vec4(textureLod(colormap, vec2(val, 0.5), 0).rgb, alpha);
             // Opacity correction
             val_color.a = 1.0 - pow(max(0.0, 1.0 - val_color.a), 1.0);
             FRAGCOLOR.rgb += (1.0 - FRAGCOLOR.a) * val_color.a * val_color.rgb;
             FRAGCOLOR.a += (1.0 - FRAGCOLOR.a) * val_color.a;
             if (FRAGCOLOR.a >= 0.95)
                 break;
         }
     }

To control the volume model we add a custom Item called ArcballController that implements an arcball controller so we can freely rotate the model. The DragHandler will send commands to the ArcballController when we click and move the mouse. The WheelHandler adds zooming to the camera.

     ArcballController {
         id: arcballController
         controlledObject: cubeModel

         function jumpToAxis(axis) {
             cameraRotation.from = arcballController.controlledObject.rotation
             cameraRotation.to = originGizmo.quaternionForAxis(
                         axis, arcballController.controlledObject.rotation)
             cameraRotation.duration = 200
             cameraRotation.start()
         }

         function jumpToRotation(qRotation) {
             cameraRotation.from = arcballController.controlledObject.rotation
             cameraRotation.to = qRotation
             cameraRotation.duration = 100
             cameraRotation.start()
         }

         QuaternionAnimation {
             id: cameraRotation
             target: arcballController.controlledObject
             property: "rotation"
             type: QuaternionAnimation.Slerp
             running: false
             loops: 1
         }
     }

     DragHandler {
         id: dragHandler
         target: null
         acceptedModifiers: Qt.NoModifier
         onCentroidChanged: {
             arcballController.mouseMoved(toNDC(centroid.position.x,
                                                centroid.position.y))
         }

         onActiveChanged: {
             if (active) {
                 view.forceActiveFocus()
                 arcballController.mousePressed(toNDC(centroid.position.x,
                                                      centroid.position.y))
             } else
                 arcballController.mouseReleased(toNDC(centroid.position.x,
                                                       centroid.position.y))
         }

         function toNDC(x, y) {
             return Qt.vector2d((2.0 * x / width) - 1.0,
                                1.0 - (2.0 * y / height))
         }
     }

     WheelHandler {
         id: wheelHandler
         orientation: Qt.Vertical
         target: null
         acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
         onWheel: event => {
                      let delta = -event.angleDelta.y * 0.01
                      cameraNode.z += cameraNode.z * 0.1 * delta
                  }
     }

We have another custom Item called OriginGizmo that is a small gizmo to show the orientation of the rotated model.

     OriginGizmo {
         id: originGizmo
         anchors.top: parent.top
         anchors.right: parent.right
         anchors.margins: 10
         width: 120
         height: 120
         targetNode: cubeModel

         onAxisClicked: axis => {
                            arcballController.jumpToAxis(axis)
                        }
     }

To control all the settings we have ScrollView to the left with a bunch of ui elements:

     ScrollView {
         id: settingsPane
         height: parent.height
         property bool hidden: false

         function toggleHide() {
             if (settingsPane.hidden) {
                 settingsPaneAnimation.from = settingsPane.x
                 settingsPaneAnimation.to = 0
             } else {
                 settingsPaneAnimation.from = settingsPane.x
                 settingsPaneAnimation.to = -settingsPane.width
             }
             settingsPane.hidden = !settingsPane.hidden
             settingsPaneAnimation.running = true
         }

         NumberAnimation on x {
             id: settingsPaneAnimation
             running: false
             from: width
             to: width
             duration: 100
         }

         Column {
             topPadding: 10
             bottomPadding: 10
             leftPadding: 20
             rightPadding: 20

             spacing: 10

             Label {
                 text: qsTr("Visible value-range:")
             }

             RangeSlider {
                 id: tSlider
                 from: 0
                 to: 1
                 first.value: 0
                 second.value: 1
             }

             Image {
                 width: tSlider.width
                 height: 20
                 source: getColormapSource(colormapCombo.currentIndex)
             }

             Label {
                 text: qsTr("Colormap:")
             }

             ComboBox {
                 id: colormapCombo
                 model: [qsTr("Cool Warm"), qsTr("Plasma"), qsTr("Viridis"), qsTr("Rainbow"), qsTr("Gnuplot")]
             }

             Label {
                 text: qsTr("Step alpha:")
             }

             Slider {
                 id: stepAlphaSlider
                 from: 0
                 value: 0.2
                 to: 1
             }

             Grid {
                 horizontalItemAlignment: Grid.AlignHCenter
                 verticalItemAlignment: Grid.AlignVCenter
                 spacing: 5
                 Label {
                     text: qsTr("Step length:")
                 }

                 TextField {
                     id: stepLengthText
                     text: "0.00391" // ~1/256
                     width: 100
                 }
             }

             CheckBox {
                 id: multipliedAlphaBox
                 text: qsTr("Multiplied alpha")
                 checked: true
             }

             CheckBox {
                 id: drawBoundingBox
                 text: qsTr("Draw Bounding Box")
                 checked: true
             }

             CheckBox {
                 id: autoRotateCheckbox
                 text: qsTr("Auto-rotate model")
                 checked: false
             }

             // X plane
             Label {
                 text: qsTr("X plane slice (position, width):")
             }

             Slider {
                 id: xSliceSlider
                 from: 0
                 to: 1
                 value: 0.5
             }

             Slider {
                 id: xSliceWidthSlider
                 from: 0
                 value: 1
                 to: 1
             }

             // Y plane
             Label {
                 text: qsTr("Y plane slice (position, width):")
             }

             Slider {
                 id: ySliceSlider
                 from: 0
                 to: 1
                 value: 0.5
             }

             Slider {
                 id: ySliceWidthSlider
                 from: 0
                 value: 1
                 to: 1
             }

             // Z plane
             Label {
                 text: qsTr("Z plane slice (position, width):")
             }

             Slider {
                 id: zSliceSlider
                 from: 0
                 to: 1
                 value: 0.5
             }

             Slider {
                 id: zSliceWidthSlider
                 from: 0
                 value: 1
                 to: 1
             }

             // Dimensions
             Label {
                 text: qsTr("Dimensions (width, height, depth):")
             }

             Row {
                 spacing: 5
                 TextField {
                     id: dataWidth
                     text: "256"
                     validator: IntValidator {
                         bottom: 1
                         top: 2048
                     }
                 }
                 TextField {
                     id: dataHeight
                     text: "256"
                     validator: IntValidator {
                         bottom: 1
                         top: 2048
                     }
                 }
                 TextField {
                     id: dataDepth
                     text: "256"
                     validator: IntValidator {
                         bottom: 1
                         top: 2048
                     }
                 }
             }

             Label {
                 text: qsTr("Scale (x, y, z):")
             }

             Row {
                 spacing: 5
                 TextField {
                     id: scaleWidth
                     text: "1"
                     validator: DoubleValidator {
                         bottom: 0.001
                         top: 1000
                         decimals: 4
                     }
                 }
                 TextField {
                     id: scaleHeight
                     text: "1"
                     validator: DoubleValidator {
                         bottom: 0.001
                         top: 1000
                         decimals: 4
                     }
                 }
                 TextField {
                     id: scaleDepth
                     text: "1"
                     validator: DoubleValidator {
                         bottom: 0.001
                         top: 1000
                         decimals: 4
                     }
                 }
             }

             Label {
                 text: qsTr("Data type:")
             }

             ComboBox {
                 id: dataTypeComboBox
                 model: ["uint8", "uint16", "int16", "float32", "float64"]
             }

             Label {
                 text: qsTr("Load Built-in Volume:")
             }

             Row {
                 spacing: 5

                 Button {
                     text: qsTr("Helix")
                     onClicked: {
                         volumeTextureData.loadAsync("file:///default_helix",
                                                     256, 256, 256, "uint8")
                         spinner.running = true
                     }
                 }

                 Button {
                     text: qsTr("Box")
                     onClicked: {
                         volumeTextureData.loadAsync("file:///default_box", 256,
                                                     256, 256, "uint8")
                         spinner.running = true
                     }
                 }

                 Button {
                     text: qsTr("Colormap")
                     onClicked: {
                         volumeTextureData.loadAsync("file:///default_colormap",
                                                     256, 256, 256, "uint8")
                         spinner.running = true
                     }
                 }
             }

             Button {
                 text: qsTr("Load Volume...")
                 onClicked: fileDialog.open()
             }
         }
     }

With all these parts working together the application is able to render and interactively control our volumes. Note that the size of the volumes that this example can render as well as the performance will be limited by your specific GPU.

Example project @ code.qt.io