Qt Quick 3D - 体积渲染示例
演示如何在Qt Quick 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 的限制。
© 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.