Qt Quick 3D - 볼류메트릭 렌더링 예제

Qt Quick 3D 에서 볼류메트릭 렌더링을 수행하는 방법을 보여줍니다.

소개

이 예제에서는 볼륨 레이 캐스팅이라는 기법으로 사용자 정의 셰이더와 3D 볼륨 텍스처를 사용하여 볼류메트릭 렌더링을 수행하는 방법을 보여줍니다. 이 예제는 원시 볼륨 파일을 읽고 렌더링하면서 사용된 컬러맵, 알파 및 슬라이스 평면과 같은 다양한 렌더링 설정을 대화형으로 수정할 수 있는 애플리케이션입니다. https://klacansky.com/open-scivis-datasets/ 에서 호스팅되는 볼륨과 잘 작동하고 올바른 크기와 배율을 자동으로 설정하도록 설계되었습니다.

구현

이 애플리케이션은 QML을 사용하며 볼륨이 포함된 View3D 및 설정이 포함된 ScrollView 이 있는 ApplicationWindow 입니다. 볼륨을 렌더링하기 위해 중간에 큐브 모델만 있는 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: 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)
    }
}

이 큐브는 볼륨에 3D 텍스처와 컬러맵에 이미지 텍스처가 있는 커스텀 셰이더를 사용합니다. 또한 전달 함수, 슬라이스 평면 등에 대한 다양한 프로퍼티가 있습니다. 볼륨 텍스처의 텍스처데이터는 VolumeTextureData 라는 커스텀 QML 유형이며 volumetexturedata.cppvolumetexturedata.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, heightdepth 옵션이 포함되어 있습니다. VolumeTextureData 에는 볼륨을 비동기적으로 로드하는 loadAsync 함수도 포함되어 있습니다. 이 함수는 loadSucceeded 또는 loadFailed 신호를 전송합니다.

이 큐브 모델에는 LineBoxGeometry 을 포함하는 두 개의 모델도 포함되어 있습니다. 이들은 볼륨의 바운딩 박스와 슬라이스 평면을 보여주는 상자입니다.

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

셰이더를 살펴봅시다. 버텍스 셰이더는 매우 간단하며 위치의 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)
                   }
}

모든 설정을 제어하기 위해 왼쪽에 여러 UI 요소와 함께 ScrollView 이 있습니다:

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에 따라 제한된다는 점에 유의하세요.

예제 프로젝트 @ code.qt.io

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