Sur cette page

Qt Quick 3D - Exemple de rendu volumétrique

Démontre comment effectuer un rendu volumétrique dans Qt Quick 3D.

Rendu du volume de la tête en 3D avec contrôle des couleurs et des tranches

Introduction

Cet exemple montre comment effectuer un rendu volumétrique en utilisant un shader personnalisé et une texture de volume 3D avec une technique appelée Volume ray casting. Cet exemple est une application qui peut lire des fichiers de volume bruts et les rendre tout en étant capable de modifier interactivement divers paramètres de rendu tels que la carte des couleurs, l'alpha et les plans de coupe utilisés. Il est conçu pour fonctionner correctement avec les volumes hébergés à l'adresse https://klacansky.com/open-scivis-datasets/ et pour définir automatiquement les dimensions et l'échelle correctes.

Mise en œuvre

L'application utilise QML et est un ApplicationWindow avec un View3D contenant le volume et un ScrollView contenant les paramètres. Pour effectuer le rendu de notre volume, nous créons une scène dans notre objet View3D avec juste un modèle de cube au milieu.

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

Ce cube utilise un shader personnalisé avec une texture 3D pour le volume et une texture image pour la carte des couleurs. Il existe également diverses propriétés pour la fonction de transfert, les plans de coupe, etc. Le textureData de la texture du volume est un type QML personnalisé appelé VolumeTextureData et est défini dans volumetexturedata.cpp et 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
    }
}

Il contient les options source, dataType, width, height et depth qui définissent comment le fichier de volume brut doit être interprété. VolumeTextureData contient également la fonction loadAsync pour le chargement asynchrone d'un volume. Elle enverra soit un signal loadSucceeded, soit un signal loadFailed.

Ce modèle de cube contient également deux modèles contenant un LineBoxGeometry. Il s'agit de boîtes montrant la boîte de délimitation du volume et les plans de coupe.

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

Jetons un coup d'œil aux shaders. Le vertex shader est très simple et, outre la projection MVP de la position, il calcule la direction du rayon de la caméra vers le modèle dans l'espace du modèle :

void MAIN()
{
    POSITION = MODELVIEWPROJECTION_MATRIX * vec4(VERTEX, 1.0);
    ray_direction_model = VERTEX - (inverse(MODEL_MATRIX) * vec4(CAMERA_POSITION, 1.0)).xyz;
}

Le fragment shader commencera par calculer le point de départ de notre rayon dans l'espace modèle en tenant compte des plans de coupe. La boucle while se déplacera le long du rayon, en échantillonnant les voxels à égale distance, en ajoutant la couleur et l'opacité pour la valeur du voxel dans la carte des couleurs.

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

Pour contrôler le modèle de volume, nous ajoutons un élément personnalisé appelé ArcballController qui implémente un contrôleur d'arcball afin de pouvoir faire pivoter librement le modèle. Le site DragHandler envoie des commandes à ArcballController lorsque nous cliquons et déplaçons la souris. Le fichier WheelHandler ajoute un zoom à la caméra.

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 / view.width) - 1.0,
                           1.0 - (2.0 * y / view.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
             }
}

Nous avons un autre élément personnalisé appelé OriginGizmo qui est un petit gadget pour montrer l'orientation du modèle pivoté.

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

Pour contrôler tous les paramètres, nous avons ScrollView à gauche avec un tas d'éléments d'interface utilisateur :

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

Avec toutes ces parties fonctionnant ensemble, l'application est capable de rendre et de contrôler interactivement nos volumes. Notez que la taille des volumes que cet exemple peut rendre ainsi que les performances seront limitées par votre GPU spécifique.

Exemple de projet @ 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.