Qt Quick 3D Introduction with glTF Assets

The Qt Quick 3D - Introduction example provides a quick introduction to creating QML-based applications with Qt Quick 3D, but it does so using only built-in primitives, such as spheres and cylinders. This page provides an introduction using glTF 2.0 assets, using some of the models from the Khronos glTF Sample Models repository.

Our Skeleton Application

Let's start with the following application. This code snippet is runnable as-is with the qml command-line tool. The result is a very green 3D view with nothing else in it.

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.Color
            clearColor: "green"
        }

        PerspectiveCamera {
            id: camera
        }

        WasdController {
            controlledObject: camera
        }
    }
}

Importing an Asset

We are going to use two glTF 2.0 models from the Sample Models repository: Sponza and Suzanne.

These models typically come with a number of texture maps and the mesh (geometry) data stored in a separate binary file, in addition to the .gltf file:

How do we get all this into our Qt Quick 3D scene?

There are a number of options:

  • Generate QML components that can be instantiated in the scene. The command-line tool to perform this conversion is the Balsam tool. Besides generating a .qml file, that is effectively a subscene, this also repacks the mesh (geometry) data into an optimized, fast-to-load format, and copies the texture map image files as well.
  • Perform the same using balsamui, a GUI frontend for Balsam.
  • If using Qt Design Studio, the asset import process is integrated into the visual design tools. Importing can be triggered, for example, by dragging and dropping the .gltf file onto the appropriate panel.
  • For glTF 2.0 assets in particular, there is a runtime option as well: the RuntimeLoader type. This allows loading a .gltf file (and the associated binary and texture data files) at runtime, without doing any pre-processing via tools such as Balsam. This is very handy in applications that wish to open and load user-provided assets. On the other hand, this approach is significantly less efficient when it comes to performance. Therefore, we will not be focusing on this approach in this introduction. Check the Qt Quick 3D - RuntimeLoader Example for an example of this approach.

Both the balsam and balsamui applications are shipped with Qt, and should be present in the directory with other similar executable tools, assuming Qt Quick 3D is installed or built. In many cases, running balsam from the command-line on the .gltf file is sufficient, without having to specify any additional arguments. It is worth being aware however of the many command-line, or interactive if using balsamui or Qt Design Studio, options. For example, when working with baked lightmaps to provide static global illumination, it is likely that one will want to pass --generateLightmapUV to get the additional lightmap UV channel generated at asset import time, instead of performing this potentially consuming process at run-time. Similarly, --generateMeshLevelsOfDetail is essential when it is desirable to have simplified versions of the meshes generated in order to have automatic LOD enabled in the scene. Other options allow generating missing data (e.g. --generateNormals) and performing various optimizations.

In balsamui the command-line options are mapped to interactive elements:

Importing via balsam

Let's get started! Assuming that the https://github.com/KhronosGroup/glTF-Sample-Models git repository is checked out somewhere, we can simply run balsam from our example application directory, by specifying an absolute path to the .gltf files:

balsam c:\work\glTF-Sample-Models\2.0\Sponza\glTF\Sponza.gltf

This gives us a Sponza.qml, a .mesh file under the meshes subdirectory, and the texture maps copied under maps.

Note: This qml file is not runnable on its own. It is a component, that should be instantiated within a 3D scene associated with a View3D.

Our project structure is very simple here, as the asset qml files live right next to our main .qml scene. This allows us to simply instantiate the Sponza type using the standard QML component system. (at run-time this will then look for Sponza.qml in the filesystem)

Just adding the model (subscene) is pointless however, since by default the materials feature the full PBR lighting calculations, so nothing is shown from our scene without a light such as DirectionalLight, PointLight, or SpotLight, or having image-based lighting enabled via the environment.

For now, we choose to add a DirectionalLight with the default settings. (meaning the color is white, and the light emits in the direction of the Z axis)

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.Color
            clearColor: "green"
        }

        PerspectiveCamera {
            id: camera
        }

        DirectionalLight {
        }

        Sponza {
        }

        WasdController {
            controlledObject: camera
        }
    }
}

Running this with the qml tool will load and run, but the scene is all empty by default since the Sponza model is behind the camera. The scale is also not ideal, e.g. moving around with WASD keys and the mouse (enabled by the WasdController) does not feel right.

To remedy this, we scale the Sponza model (subscene) by 100 along the X, Y, and Z axis. In addition, the camera's starting Y position is bumped to 100.

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.Color
            clearColor: "green"
        }

        PerspectiveCamera {
            id: camera
            y: 100
        }

        DirectionalLight {
        }

        Sponza {
            scale: Qt.vector3d(100, 100, 100)
        }

        WasdController {
            controlledObject: camera
        }
    }
}

Running this gives us:

With the mouse and the WASDRF keys we can move around:

Note: We mentioned subscene a number of times above as an alternative to "model". Why is this? While not obvious with the Sponza asset, which in its glTF form is a single model with 103 submeshes, mapping to a single Model object with 103 elements in its materials list, an asset can contain any number of models, each with multiple submeshes and associated materials. These Models can form parent-child relationships and can be combined with additional nodes to perform transforms such as translate, rotate, or scale. It is therefore more appropriate to look at the imported asset as a complete subscene, an arbitrary tree of nodes, even if the rendered result is visually perceived as a single model. Open the generated Sponza.qml, or any other QML file generated from such assets, in a plain text editor to get an impression of the structure (which naturally always depends on how the source asset, in this case the glTF file, was designed).

Importing via balsamui

For our second model, let's use the graphical user interface of balsam instead.

Running balsamui opens the tool:

Let's import the Suzanne model. This is a simpler model with two texture maps.

As there is no need for any additional configuration options, we can just Convert. The result is the same as running balsam: a Suzanne.qml and some additional files generated in the specific output directory.

From this point on, working with the generated assets is the same as in the previous section.

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.Color
            clearColor: "green"
        }

        PerspectiveCamera {
            id: camera
            y: 100
        }

        DirectionalLight {
        }

        Sponza {
            scale: Qt.vector3d(100, 100, 100)
        }

        Suzanne {
            y: 100
            scale: Qt.vector3d(50, 50, 50)
            eulerRotation.y: -90
        }

        WasdController {
            controlledObject: camera
        }
    }
}

Again, a scale is applied to the instantiated Suzanne node, and the Y position is altered a bit so that the model does not end up in the floor of the Sponza building.

All properties can be changed, bound to, and animated, just like with Qt Quick. For example, let's apply a continuous rotation to our Suzanne model:

Suzanne {
    y: 100
    scale: Qt.vector3d(50, 50, 50)
    NumberAnimation on eulerRotation.y {
        from: 0
        to: 360
        duration: 3000
        loops: Animation.Infinite
    }
}

Making it Look Better

More light

Now, our scene is a bit dark. Let's add another light. This time a PointLight, and one that casts a shadow.

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.Color
            clearColor: "green"
        }

        PerspectiveCamera {
            id: camera
            y: 100
        }

        DirectionalLight {
        }

        Sponza {
            scale: Qt.vector3d(100, 100, 100)
        }

        PointLight {
            y: 200
            color: "#d9c62b"
            brightness: 5
            castsShadow: true
            shadowFactor: 75
        }

        Suzanne {
            y: 100
            scale: Qt.vector3d(50, 50, 50)
            NumberAnimation on eulerRotation.y {
                from: 0
                to: 360
                duration: 3000
                loops: Animation.Infinite
            }
        }

        WasdController {
            controlledObject: camera
        }
    }
}

Launching this scene and moving the camera around a bit reveals that this is indeed starting to look better than before:

Light debugging

The PointLight is placed slightly above the Suzanne model. When designing the scene using visual tools, such as Qt Design Studio, this is obvious, but when developing without any design tools it may become handy to be able to quickly visualize the location of lights and other nodes.

This we can do by adding a child node, a Model to the PointLight. The position of the child node is relative to the parent, so the default (0, 0, 0) is effectively the position of the PointLight in this case. Enclosing the light within some geometry (the built-in cube in this case) is not a problem for the standard real-time lighting calculations since this system has no concept of occlusion, meaning the light has no problems with traveling through "walls". If we used pre-baked lightmaps, where lighting is calculated using raytracing, that would be a different story. In that case we would need to make sure the cube is not blocking the light, perhaps by moving our debug cube a bit above the light.

PointLight {
    y: 200
    color: "#d9c62b"
    brightness: 5
    castsShadow: true
    shadowFactor: 75
    Model {
        source: "#Cube"
        scale: Qt.vector3d(0.01, 0.01, 0.01)
        materials: PrincipledMaterial {
            lighting: PrincipledMaterial.NoLighting
        }
    }
}

Another trick we use here is turning off lighting for the material used with the cube. It will just appear using the default base color (white), without being affected by lighting. This is handy for objects used for debugging and visualizing purposes.

The result, note the small, white cube appearing, visualizing the position of the PointLight:

Skybox and image-based lighting

Another obvious improvement is doing something about the background. That green clear color is not quite ideal. How about some environment that also contributes to lighting?

As we do not necessarily have suitable HDRI panorama image available, let's use a procedurally generated high dynamic range sky image. This is easy to do with the help of ProceduralSkyTextureData and Texture's support for non-file based, dynamically generated image data. Instead of specifying source, we rather use the textureData property.

environment: SceneEnvironment {
    backgroundMode: SceneEnvironment.SkyBox
    lightProbe: Texture {
        textureData: ProceduralSkyTextureData {
        }
    }
}

Note: The example code prefers defining objects inline. This is not mandatory, the SceneEnvironment or ProceduralSkyTextureData objects could have also been defined elsewhere in the object tree, and then referenced by id.

As a result, we have both a skybox and improved lighting. (the former due to the backgroundMode being set to SkyBox and light probe being set to a valid Texture; the latter due to light probe being set to a valid Texture)

Basic Performance Investigations

To get some basic insights into the resource and performance aspects of the scene, it is a good idea to add a way to show an interactive DebugView item early on in the development process. Here we choose to add a Button that toggles the DebugView, both anchored in the top-right corner.

import QtQuick
import QtQuick.Controls
import QtQuick3D
import QtQuick3D.Helpers

Item {
    width: 1280
    height: 720

    View3D {
        id: view3D
        anchors.fill: parent

        environment: SceneEnvironment {
            backgroundMode: SceneEnvironment.SkyBox
            lightProbe: Texture {
                textureData: ProceduralSkyTextureData {
                }
            }
        }

        PerspectiveCamera {
            id: camera
            y: 100
        }

        DirectionalLight {
        }

        Sponza {
            scale: Qt.vector3d(100, 100, 100)
        }

        PointLight {
            y: 200
            color: "#d9c62b"
            brightness: 5
            castsShadow: true
            shadowFactor: 75
            Model {
                source: "#Cube"
                scale: Qt.vector3d(0.01, 0.01, 0.01)
                materials: PrincipledMaterial {
                    lighting: PrincipledMaterial.NoLighting
                }
            }
        }

        Suzanne {
            y: 100
            scale: Qt.vector3d(50, 50, 50)
            NumberAnimation on eulerRotation.y {
                from: 0
                to: 360
                duration: 3000
                loops: Animation.Infinite
            }
        }

        WasdController {
            controlledObject: camera
        }
    }

    Button {
        anchors.right: parent.right
        text: "Toggle DebugView"
        onClicked: debugView.visible = !debugView.visible
        DebugView {
            id: debugView
            source: view3D
            visible: false
            anchors.top: parent.bottom
            anchors.right: parent.right
        }
    }
}

This panel shows live timings, allows examining the live list of texture maps and meshes, and gives an insight into the render passes that need to be performed before the final color buffer can be rendered.

Due to making the PointLight a shadow casting light, there are multiple render passes involved:

In the Textures section we see the texture maps from the Suzanne and Sponza assets (the latter has a lot of them), as well as the procedurally generated sky texture.

The Models page presents no surprises:

On the Tools page there are some interactive controls to toggle wireframe mode and various material overrides.

Here with wireframe mode enabled and forcing rendering to only use the base color component of the materials:

This concludes our tour of the basics of building a Qt Quick 3D scene with imported assets.

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