Qt Quick 3D - Beispiel für volumetrisches Rendering
Zeigt, wie man volumetrisches Rendering in Qt Quick 3D durchführt.
Einführung
Dieses Beispiel demonstriert das volumetrische Rendering unter Verwendung eines benutzerdefinierten Shaders und einer 3D-Volumentextur mit einer Technik namens Volume Ray Casting. Dieses Beispiel ist eine Anwendung, die rohe Volumendateien lesen und rendern kann, während sie in der Lage ist, verschiedene Rendering-Einstellungen wie die verwendete Farbkarte, Alpha- und Slice-Ebenen interaktiv zu ändern. Es ist so konzipiert, dass es gut mit den auf https://klacansky.com/open-scivis-datasets/ gehosteten Volumes funktioniert und automatisch die richtigen Abmessungen und Skalierungen einstellt.
Implementierung
Die Anwendung verwendet QML und ist eine ApplicationWindow mit einer View3D, die das Volumen enthält, und einer ScrollView, die die Einstellungen enthält. Um unser Volumen zu rendern, erstellen wir eine Szene in unserem View3D Objekt mit einem Würfelmodell in der Mitte.
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) } }
Dieser Würfel verwendet einen eigenen Shader mit einer 3D-Textur für das Volumen und einer Bildtextur für die Farbkarte. Außerdem gibt es verschiedene Eigenschaften für die Übertragungsfunktion, die Slice-Ebenen usw. Die textureData der Volumentextur ist ein benutzerdefinierter QML-Typ namens VolumeTextureData
und ist in volumetexturedata.cpp
und volumetexturedata.h
definiert.
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 } }
Er enthält die Optionen source
, dataType
, width
, height
und depth
, die festlegen, wie die rohe Volumendatei interpretiert werden soll. VolumeTextureData
enthält auch die Funktion loadAsync
zum asynchronen Laden eines Volumens. Es wird entweder ein loadSucceeded
oder ein loadFailed
Signal gesendet.
Dieses Würfelmodell enthält auch zwei Modelle, die ein LineBoxGeometry
enthalten. Dies sind Boxen, die den Begrenzungsrahmen des Volumens und die Schnittebenen zeigen.
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) }
Werfen wir einen Blick auf die Shader. Der Vertex-Shader ist sehr einfach und wird neben der MVP-Projektion der Position auch die Richtung des Strahls von der Kamera zum Modell im Modellraum berechnen:
void MAIN() { POSITION = MODELVIEWPROJECTION_MATRIX * vec4(VERTEX, 1.0); ray_direction_model = VERTEX - (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION, 1.0)).xyz; }
Der Fragment-Shader beginnt mit der Berechnung, wo unser Strahl im Modellraum beginnen wird, wobei die Schnittebenen berücksichtigt werden. Die while
-Schleife schreitet entlang des Strahls, sampelt die Voxel in gleichem Abstand und fügt die Farbe und die Opazität für den Wert des Voxels in der Colormap hinzu.
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; } }
Zur Steuerung des Volumenmodells fügen wir ein benutzerdefiniertes Element namens ArcballController hinzu, das einen Arcball-Controller implementiert, damit wir das Modell frei drehen können. Die DragHandler sendet Befehle an den ArcballController, wenn wir mit der Maus klicken und sie bewegen. Die WheelHandler fügt der Kamera ein Zooming hinzu.
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 } }
Wir haben ein weiteres benutzerdefiniertes Element namens OriginGizmo, das ein kleines Gizmo ist, um die Ausrichtung des gedrehten Modells anzuzeigen.
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) } }
Um alle Einstellungen zu steuern, haben wir ScrollView auf der linken Seite mit einer Reihe von UI-Elementen:
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() } } }
Durch das Zusammenspiel all dieser Teile ist die Anwendung in der Lage, unsere Volumen zu rendern und interaktiv zu steuern. Beachten Sie, dass die Größe der Volumina, die dieses Beispiel rendern kann, sowie die Leistung durch Ihre spezifische GPU begrenzt werden.
© 2025 The Qt Company Ltd. Documentation contributions included herein are the copyrights of their respective owners. The documentation provided herein is licensed under the terms of the GNU Free Documentation License version 1.3 as published by the Free Software Foundation. Qt and respective logos are trademarks of The Qt Company Ltd. in Finland and/or other countries worldwide. All other trademarks are property of their respective owners.