本页内容

Qt Quick 3D - 体积渲染示例

演示如何在Qt Quick 3D 中进行体积渲染。

带色图和切片控制的3D头部体积渲染

简介

本示例演示了如何使用自定义着色器和 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: window.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: window.sliceSliderMin(
                                        xSliceSlider.value,
                                        xSliceWidthSlider.value,
                                        ySliceSlider.value,
                                        ySliceWidthSlider.value,
                                        zSliceSlider.value,
                                        zSliceWidthSlider.value)
        property vector3d sliceMax: window.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: window.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: window.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;
}

片段着色器首先会根据切片平面,计算光线行进(ray-marching)光线在模型空间中的起始位置。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 的自定义项,它实现了弧形球控制器,从而可以自由旋转模型并缩放摄像机。

ArcballController {
    id: arcballController
    camera: cameraNode
    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 = 200
        cameraRotation.start()
    }

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

我们还有另一个名为 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: view.width
        to: view.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: window.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 的限制。

示例项目 @ code.qt.io

© 2026 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.