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