Qt Quick 3D - Volumetric Rendering Example

 // Copyright (C) 2023 The Qt Company Ltd.
 // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
 import QtQuick
 import QtQuick3D
 import QtQuick3D.Helpers
 import QtQuick.Controls
 import QtQuick.Dialogs
 import Qt.labs.folderlistmodel
 import QtQuick.Controls.Universal

 import VolumetricExample
 import "SpacingMap.mjs" as SpacingMap

 ApplicationWindow {
     id: window
     width: 1200
     height: 1080
     visible: true

     Universal.theme: Universal.Dark

     FileDialog {
         id: fileDialog
         onAccepted: {
             loadFile(selectedFile)
         }
     }

     function clamp(number, min, max) {
         return Math.max(min, Math.min(number, max))
     }

     function loadFile(selectedFile) {
         var width = parseInt(dataWidth.text)
         var height = parseInt(dataHeight.text)
         var depth = parseInt(dataDepth.text)
         var dataSize = dataTypeComboBox.currentText

         // Parses file names of the form:
         // boston_teapot_256x256x178_uint8.raw
         const re = new RegExp(".?([0-9]+)x([0-9]+)x([0-9]+)_([a-zA-Z0-9]+)\.raw")
         let matches = re.exec(String(selectedFile))
         if (matches.length === 5) {
             width = parseInt(matches[1])
             height = parseInt(matches[2])
             depth = parseInt(matches[3])
             dataSize = matches[4]
         }

         let dimensions = Qt.vector3d(width, height, depth).normalized()
         var spacing = SpacingMap.get(String(selectedFile)).times(dimensions)
         let maxSide = Math.max(Math.max(spacing.x, spacing.y), spacing.z)
         spacing = spacing.times(1 / maxSide)

         volumeTextureData.loadAsync(selectedFile, width, height,
                                     depth, dataSize)
         spinner.running = true
     }

     function getColormapSource(currentIndex) {
         switch (currentIndex) {
         case 0:
             return "images/colormap-coolwarm.png"
         case 1:
             return "images/colormap-plasma.png"
         case 2:
             return "images/colormap-viridis.png"
         case 3:
             return "images/colormap-rainbow.png"
         case 4:
             return "images/colormap-gnuplot.png"
         default:
             break
         }
         return ""
     }

     // position and width are normalized [0..1]
     function sliceSliderMin(posX, widthX, posY, widthY, posZ, widthZ) {
         let x = clamp(posX - 0.5 * widthX, 0, 1 - widthX)
         let y = clamp(posY - 0.5 * widthY, 0, 1 - widthY)
         let z = clamp(posZ - 0.5 * widthZ, 0, 1 - widthZ)
         return Qt.vector3d(x, y, z)
     }

     // position and width are normalized [0..1]
     function sliceSliderMax(posX, widthX, posY, widthY, posZ, widthZ) {
         let x = clamp(posX + 0.5 * widthX, widthX, 1)
         let y = clamp(posY + 0.5 * widthY, widthY, 1)
         let z = clamp(posZ + 0.5 * widthZ, widthZ, 1)
         return Qt.vector3d(x, y, z)
     }

     function sliceBoxPosition(x, y, z, xWidth, yWidth, zWidth) {
         let min = sliceSliderMin(x, xWidth, y, yWidth, z, zWidth)
         let max = sliceSliderMax(x, xWidth, y, yWidth, z, zWidth)
         let xMid = (min.x + max.x) * 0.5 - 0.5
         let yMid = (min.y + max.y) * 0.5 - 0.5
         let zMid = (min.z + max.z) * 0.5 - 0.5
         return Qt.vector3d(xMid, yMid, zMid).times(100)
     }

     Connections {
         target: volumeTextureData
         function onLoadSucceeded(source, width, height, depth, dataType) {
             var spacing = SpacingMap.get(String(source)).times(
                         Qt.vector3d(width, height, depth).normalized())
             let maxSide = Math.max(Math.max(spacing.x, spacing.y), spacing.z)
             spacing = spacing.times(1 / maxSide)

             switch (dataType) {
             case 'uint8':
                 dataTypeComboBox.currentIndex = 0
                 break
             case 'uint16':
                 dataTypeComboBox.currentIndex = 1
                 break
             case 'int16':
                 dataTypeComboBox.currentIndex = 2
                 break
             case 'float32':
                 dataTypeComboBox.currentIndex = 3
                 break
             case 'float64':
                 dataTypeComboBox.currentIndex = 4
                 break
             }

             dataWidth.text = width
             dataHeight.text = height
             dataDepth.text = depth
             scaleWidth.text = parseFloat(spacing.x.toFixed(4))
             scaleHeight.text = parseFloat(spacing.y.toFixed(4))
             scaleDepth.text = parseFloat(spacing.z.toFixed(4))
             stepLengthText.text = parseFloat((1 / cubeModel.maxSide).toFixed(6))
             volumeTextureData.source = source
             spinner.running = false
         }
         function onLoadFailed(source, width, height, depth, dataType) {
             spinner.running = false
         }
     }

     View3D {
         id: view

         x: settingsPane.x + settingsPane.width
         width: parent.width - x
         height: parent.height

         camera: cameraNode

         PerspectiveCamera {
             id: cameraNode
             z: 300
         }

         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)
             }
         }

         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
                      }
         }

         FrameAnimation {
             running: autoRotateCheckbox.checked
             onTriggered: {
                 arcballController.mousePressed(Qt.vector2d(0, 0))
                 arcballController.mouseMoved(Qt.vector2d(0.01, 0))
                 arcballController.mouseReleased(Qt.vector2d(0.01, 0))
             }
         }

         Keys.onPressed: event => {
                             if (event.key === Qt.Key_Space) {
                                 let rotation = originGizmo.quaternionAlign(
                                     arcballController.controlledObject.rotation)
                                 arcballController.jumpToRotation(rotation)
                             } else if (event.key === Qt.Key_S) {
                                 settingsPane.toggleHide()
                             } else if (event.key === Qt.Key_Left
                                        || event.key === Qt.Key_A) {
                                 let rotation = originGizmo.quaternionRotateLeft(
                                     arcballController.controlledObject.rotation)
                                 arcballController.jumpToRotation(rotation)
                             } else if (event.key === Qt.Key_Right
                                        || event.key === Qt.Key_D) {
                                 let rotation = originGizmo.quaternionRotateRight(
                                     arcballController.controlledObject.rotation)
                                 arcballController.jumpToRotation(rotation)
                             }
                         }
     }

     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)
                        }
     }

     RoundButton {
         id: iconOpen
         text: "\u2630" // Unicode Character 'TRIGRAM FOR HEAVEN', no qsTr()
         x: settingsPane.x + settingsPane.width + 10
         y: 10
         onClicked: settingsPane.toggleHide()
     }

     Spinner {
         id: spinner
         running: false
         anchors.right: parent.right
         anchors.bottom: parent.bottom
         anchors.margins: 10
     }

     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()
             }
         }
     }
 }