Qt Quick 3D - 体积渲染示例
演示如何在Qt Quick 3D 中进行体积渲染。
简介
本示例演示了如何使用自定义着色器和三维体积纹理,通过一种名为 "体积光线投射"的技术进行体积渲染。本示例是一个可以读取原始体积文件并对其进行渲染的应用程序,同时还可以交互式地修改各种渲染设置,如使用的颜色映射、alpha 和切片平面。它的设计目的是与https://klacansky.com/open-scivis-datasets/上托管的卷文件配合使用,并自动设置正确的尺寸和缩放比例。
实施
该应用程序使用 QML,是一个ApplicationWindow ,其中View3D 包含体积,ScrollView 包含设置。为了渲染体积,我们在View3D 对象中创建了一个场景,中间只有一个立方体模型。
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: PrincipledMaterial { baseColor: "#323232" lighting: PrincipledMaterial.NoLighting } receivesShadows: false castsShadows: false } Model { visible: drawBoundingBox.checked geometry: LineBoxGeometry {} materials: PrincipledMaterial { baseColor: "#323232" lighting: PrincipledMaterial.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) } }
这个立方体使用了一个自定义着色器,该着色器带有一个用于体积的 3D 纹理和一个用于颜色贴图的图像纹理。此外,还有用于传递函数、切平面等的各种属性。体积纹理的 textureData 是一种名为VolumeTextureData
的自定义 QML 类型,定义在volumetexturedata.cpp
和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 } }
它包含source
、dataType
、width
、height
和depth
选项,这些选项定义了如何解释原始卷文件。VolumeTextureData
还包含用于异步加载卷的函数loadAsync
。它将发送loadSucceeded
或loadFailed
信号。
该立方体模型还包含两个包含LineBoxGeometry
的模型。这些方框显示了体积的边界框和切片平面。
Model { visible: drawBoundingBox.checked geometry: LineBoxGeometry {} materials: PrincipledMaterial { baseColor: "#323232" lighting: PrincipledMaterial.NoLighting } receivesShadows: false castsShadows: false } Model { visible: drawBoundingBox.checked geometry: LineBoxGeometry {} materials: PrincipledMaterial { baseColor: "#323232" lighting: PrincipledMaterial.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) }
让我们来看看着色器。顶点着色器非常简单,除了位置的 MVP 投影外,还将计算从摄像机到模型空间中模型的光线方向:
void MAIN() { POSITION = MODELVIEWPROJECTION_MATRIX * vec4(VERTEX, 1.0); ray_direction_model = VERTEX - (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION, 1.0)).xyz; }
片段着色器将首先计算我们的光线行进射线在模型空间中的起始位置,并将切片平面考虑在内。while
循环将沿着射线前进,等距离采样体素,并在色谱图中添加颜色和体素值。
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; } }
为了控制体块模型,我们添加了一个名为 ArcballController 的自定义项,它实现了一个弧线球控制器,因此我们可以自由旋转模型。当我们点击和移动鼠标时,DragHandler 将向 ArcballController 发送命令。WheelHandler 将为摄像机添加缩放功能。
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 } }
我们还有一个名为 OriginGizmo 的自定义项,它是一个小工具,用于显示旋转模型的方向。
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) } }
为了控制所有设置,我们在ScrollView 的左侧添加了大量 UI 元素:
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() } } }
有了所有这些部分的协同工作,应用程序就能渲染并交互式控制我们的体积。请注意,本示例可渲染的体积大小和性能将受到您的特定 GPU 的限制。
© 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.