Sur cette page

Qt Quick 3D - XR Advanced Touch

Démonstration du toucher sur des écrans incurvés et de la gestion du toucher sur des modèles 3D.

Exemple de panneau d'interface utilisateur incurvé et d'objets 3D pouvant être peints

Cet exemple s'appuie sur l'exemple XR Simple Touch pour démontrer des techniques d'interaction tactile plus avancées dans Qt Quick 3D Xr :

  • Interaction tactile avec des éléments d'interface utilisateur 2D rendus sur une surface 3D incurvée.
  • Gestion directe du toucher sur des modèles 3D à l'aide de coordonnées UV.
  • Saisie et manipulation d'objets 3D à l'aide de gestes de pincement.
  • Création de maillages courbes procéduraux pour les écrans immersifs.
  • Création de textures personnalisées à peindre avec C++.

Affichage incurvé avec Source Item

L'une des principales caractéristiques de cet exemple est l'affichage d'une interface utilisateur 2D sur une surface incurvée. Nous créons un composant de maillage procédural personnalisé, CurvedMesh, qui génère un plan courbe :

// Copyright (C) 2025 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause

import QtQuick
import QtQuick3D
import QtQuick3D.Helpers

ProceduralMesh {
        property real partitions: 50
        property real segments: 1
        property real radius: 50.0
        property real height: 50.0
        property real width: 80
        property var meshArrays: generateMesh(partitions , width, height, radius)
        positions: meshArrays.verts
        normals: meshArrays.normals
        uv0s: meshArrays.uvs
        indexes: meshArrays.indices

        function generateMesh(partitions : real, width: real, height: real, radius: real) : var {
            let verts = []
            let normals = []
            let uvs = []
            let indices = []

            // width = angleSpan * radius
            const angleSpan = width / radius

            for (let i = 0; i <= partitions ; ++i) {
                for (let j = 0; j <= 1; ++j) {
                    const u = i / partitions ;
                    const v = j;

                    const x = u - 0.5 // centered
                    const angle = x * angleSpan

                    const posZ = radius - radius * Math.cos(angle)
                    const posY = height * v
                    const posX = radius * Math.sin(angle)

                    verts.push(Qt.vector3d(posX, posY, posZ));

                    let normal = Qt.vector3d(0 - posX, 0, radius - posZ).normalized();
                    normals.push(normal);

                    uvs.push(Qt.vector2d(u, v));
                }
            }

            for (let i = 0; i < partitions ; ++i) {
                let a = (segments + 1) * i;
                let b = (segments + 1) * (i + 1);
                let c = (segments + 1) * (i + 1) + 1;
                let d = (segments + 1) * i + 1;

                // Generate two triangles for each quad in the mesh
                // Adjust order to be counter-clockwise
                indices.push(a, b, d);
                indices.push(b, c, d);
            }
            return { verts: verts, normals: normals, uvs: uvs, indices: indices }
        }
    }

Le site CurvedMesh hérite de ProceduralMesh et génère des sommets, des normales et des coordonnées UV pour une surface courbe. La courbure est contrôlée par la propriété radius, qui détermine le degré de courbure de la surface. Le maillage est divisé en segments partitions pour créer une courbe lisse.

Nous appliquons ensuite cette géométrie courbée à une Model avec une PrincipledMaterial qui utilise une sourceItem pour rendre le contenu 2D :

Model {
    objectName: "curved screen"
    x: 0
    y: -40
    z: -50
    // mesh origin is at bottom of screen; scene origin is at eye height
    geometry: CurvedMesh {
        width: height * 32 / 9
        height: 40
        radius: 80
    }
    visible: true
    pickable: true
    materials: PrincipledMaterial {
        baseColorMap: Texture {
            id: screenTexture
            sourceItem: Rectangle {
                id: uiItem
                width: height * 32 / 9
                height: 500

                property real dim: Math.min(width, height)

                color: "#dd000000"

                ColumnLayout {
                    id: leftItem
                    anchors.top: parent.top
                    anchors.bottom: parent.bottom
                    anchors.left: parent.left
                    anchors.right: centralItem.left
                    anchors.margins: 15
                    spacing: 10

                    GridLayout {
                        rows: 3
                        columns: 3
                        columnSpacing: 5
                        rowSpacing: 5

                        Layout.alignment: Qt.AlignHCenter | Qt.AlignVCenter

                        ColorSwatch {
                            color: "red"
                        }
                        ColorSwatch {
                            color: "orange"
                        }
                        ColorSwatch {
                            color: "yellow"
                        }
                        ColorSwatch {
                            color: "green"
                        }
                        ColorSwatch {
                            color: "blue"
                        }
                        ColorSwatch {
                            color: "purple"
                        }
                        ColorSwatch {
                            color: "white"
                        }
                        ColorSwatch {
                            color: "gray"
                        }
                        ColorSwatch {
                            color: "black"
                        }
                    }
                    RowLayout {
                        Label {
                            text: "Stroke width: " + penWidthSlider.value.toFixed(1)
                        }
                        Slider {
                            id: penWidthSlider
                            Layout.fillWidth: true
                            from: 1
                            to: 30
                            value: 10
                            onValueChanged: painterItem.setPenWidth(value)
                        }
                    }
                }

                Rectangle {
                    id: centralItem
                    width: uiItem.dim - 10
                    height: uiItem.dim - 10

                    anchors.centerIn: parent
                    color: "gray"

                    TextureItem {
                        id: painterItem
                        anchors.fill: parent
                        anchors.margins: 2
                        MultiPointTouchArea {
                            anchors.fill: parent
                            onPressed: (list) => {
                                           for (const pt of list) {
                                               painterItem.setPoint(pt.x, pt.y, pt.pointId, pt.pressed)
                                           }
                                       }
                            onUpdated: (list) => {
                                           for (const pt of list) {
                                               painterItem.setPoint(pt.x, pt.y, pt.pointId, pt.pressed)
                                           }
                                       }
                            onReleased: (list) => {
                                            for (const pt of list) {
                                                painterItem.setPoint(pt.x, pt.y, pt.pointId, pt.pressed)
                                            }
                                        }
                        }
                        Component.onCompleted: {
                            // Let initial colors be the same as the hand colors
                            setColor("green", 1)
                            setColor("red", 2)
                        }
                    }
                }

                Item {
                    id: rightItem
                    anchors.left: centralItem.right
                    anchors.top: parent.top
                    anchors.bottom: parent.bottom
                    anchors.right: parent.right

                    GroupBox {
                        anchors.centerIn: parent
                        title: "3D Model"
                        ColumnLayout {
                            ColumnLayout {
                                id: radioButtons
                                RadioButton {
                                    text: "Torus"
                                    checked: true
                                }
                                RadioButton {
                                    text: "Cube"
                                }
                                RadioButton {
                                    text: "Sphere"
                                }
                                RadioButton {
                                    text: "Cylinder"
                                }
                                RadioButton {
                                    text: "Cone"
                                }
                            }
                            RowLayout {
                                Label {
                                    text: "Scale: " + scaleSlider.value.toFixed(2)
                                }
                                Slider {
                                    id: scaleSlider
                                    Layout.fillWidth: true
                                    from: 0.01
                                    to: 2
                                    value: 0.25
                                }
                            }
                        }
                        ButtonGroup {
                            buttons: radioButtons.children
                            onCheckedButtonChanged: {
                                selectableModel.meshName = checkedButton.text
                            }
                        }
                    }
                }
            }
        }
        emissiveMap: screenTexture
        emissiveFactor: Qt.vector3d(0.8, 0.8, 0.8)
    }
    opacity: 0.99 // enable alpha blending
}

La propriété sourceItem contient un Qt Quick Rectangle standard avec une interface utilisateur complexe comprenant des échantillons de couleurs, des curseurs et un canevas de dessin. Ce contenu 2D est automatiquement projeté sur la surface 3D incurvée. Les événements tactiles sur la surface incurvée sont convertis en coordonnées 2D et transmis aux éléments Qt Quick appropriés.

Remarque : les éléments emissiveMap et emissiveFactor sont utilisés pour que l'écran semble auto-éclairé, ce qui améliore la visibilité dans différentes conditions d'éclairage. La légère transparence (opacité : 0,99) permet le mélange alpha.

Texture personnalisable à peindre

Le canevas de dessin de l'interface utilisateur utilise une classe C++ personnalisée, TextureItem, qui étend QQuickPaintedItem pour fournir une texture à peindre qui peut être utilisée dans les matériaux 3D :

class TextureItem : public QQuickPaintedItem {
    Q_OBJECT
    Q_PROPERTY(const QQuick3DTextureData* textureData READ textureData NOTIFY textureDataChanged FINAL)
    QML_ELEMENT

public:
    TextureItem(QQuickItem *parent = nullptr);

    void paint(QPainter *painter) override;
    const QQuick3DTextureData *textureData() const;

    Q_INVOKABLE void setPoint(float x, float y, int id, bool pressed);
    Q_INVOKABLE void setUv(float u, float v, int id, bool pressed);
    Q_INVOKABLE void clear(const QColor &color);
    Q_INVOKABLE void setColor(const QColor &newColor, int id = 0);
    Q_INVOKABLE void setPenWidth(qreal newPenWidth);
    Q_INVOKABLE void resetPoint(int id);

public slots:
    void updateImage();

signals:
    void textureDataChanged();
    void colorChanged();
    void penWidthChanged();

private:

    struct TouchPointState {
        QColor color = "red";
        qreal penWidth = 10; // unused
        double alpha = 0.5;  // smoothing factor (0..1)
        bool initialized = false;
        QPointF prevFiltered;
        QPointF prevRaw;
        qint64 prevTime = 0; // timestamp in milliseconds

        void reset() {
            initialized = false;
            prevTime = 0;
        }

        QPointF filter(const QPointF &unfiltered, qint64 timestamp) {
            prevRaw = unfiltered;
            if (!initialized) {
                prevFiltered = unfiltered;
                prevTime = timestamp;
                initialized = true;
            } else {
                // Adjust alpha based on time delta
                qint64 dt = timestamp - prevTime;
                double adaptiveAlpha = qMin(1.0, alpha * (dt / 16.0)); // 16ms = ~60fps baseline

                prevFiltered.setX(adaptiveAlpha * unfiltered.x() + (1 - adaptiveAlpha) * prevFiltered.x());
                prevFiltered.setY(adaptiveAlpha * unfiltered.y() + (1 - adaptiveAlpha) * prevFiltered.y());
                prevTime = timestamp;
            }
            return prevFiltered;
        }
    };

La classe TextureItem conserve une image interne QImage sur laquelle il est possible de dessiner à l'aide de la classe QPainter. Les données de l'image sont ensuite exposées en tant que QQuick3DTextureData qui peut être utilisé dans les textures matérielles. Cela nous permet de créer des textures dynamiques, modifiées par l'utilisateur et mises à jour en temps réel.

La méthode clé est setUv(), qui gère l'entrée tactile dans l'espace de coordonnées UV :

void TextureItem::setUv(float u, float v, int id, bool pressed)
{
    auto &s = state[id];

    bool inRange = u >= 0 && u <= 1 && v >=0 && v <= 1;
    if (!pressed || !inRange) {
        s.reset();
        return;
    }

Cette méthode met en œuvre plusieurs fonctions sophistiquées :

  • Lissage temporel avec filtrage adaptatif pour réduire la gigue.
  • Détection et traitement de l'enveloppement des coordonnées UV (lorsque le dessin traverse les limites de la texture).
  • Suivi de l'état de chaque point de contact pour la prise en charge du multi-touch.

Gestion du toucher sur des modèles 3D

L'exemple montre une interaction tactile directe avec des modèles 3D grâce au composant amélioré XrGadget. Lorsqu'une main touche un modèle 3D, la position du toucher est convertie en coordonnées UV et transmise au modèle :

const touchState = view.touchpointState(root.touchId)
const gadget = touchState.model as XrGadget
if (gadget) {
    gadget.handleTouch(touchState.uvPosition, root.touchId, touchState.pressed)
}

Le composant XrGadget reçoit ces événements tactiles par l'intermédiaire de sa fonction handleTouch:

onTouched: (uvPosition, touchID, pressed) => {
    if (!isGrabbing)
        painterItem.setUv(uvPosition.x, uvPosition.y, touchID, pressed);
}

Lorsque l'utilisateur ne saisit pas le modèle, les événements tactiles sont transmis au composant TextureItem, qui dessine sur la texture du modèle. Cela permet aux utilisateurs de peindre directement sur la surface de l'objet 3D.

Saisir et manipuler des objets 3D

L'exemple met également en œuvre la fonctionnalité de saisie et de déplacement à l'aide du geste de pincement :

XrInputAction {
    id: grabAction
    controller: XrInputAction.RightHand
    actionId: [ XrInputAction.IndexFingerPinch ]

    onPressedChanged: {
        if (pressed)
            grabController.startGrab()
    }
}

Lorsque l'utilisateur effectue un geste de pincement avec sa main droite, le système capture le décalage et la rotation entre la main et l'objet sélectionné :

XrController {
    id: grabController
    controller: XrController.RightHand
    property vector3d grabOffset
    property quaternion grabRotation

    function startGrab() {
        const scenePos = selectableModel.scenePosition
        const sceneRot = selectableModel.sceneRotation
        grabOffset = scenePos.minus(scenePosition)
        grabRotation = rotation.inverted().times(sceneRot)
    }

    onRotationChanged: {
        if (isGrabbing) {
            let newPos = scenePosition.plus(grabOffset)
            let newRot = sceneRotation.times(grabRotation)

            selectableModel.setPosition(newPos)
            selectableModel.setRotation(newRot)
        }
    }
}

Lorsque la main se déplace, l'objet suit en conservant le décalage et la rotation relative d'origine. Cela crée un moyen naturel et intuitif de positionner des objets 3D dans l'espace.

Sélection de couleurs et multi-touch

La palette de couleurs sur l'écran incurvé illustre l'interaction multi-touch. Chaque main peut sélectionner indépendamment une couleur en touchant un échantillon de couleur :

component ColorSwatch: Rectangle {
    implicitWidth: 100
    implicitHeight: 100

    function perceivedBrightness() {
        return 0.2 * color.r + 0.7 * color.g + 0.1 * color.b
    }

    function contrastColor() {
        if (perceivedBrightness() > 0.6)
            return Qt.darker(color, 1.1)

        const h = color.hslHue
        const s = color.hslSaturation
        const l = color.hslLightness + 0.2

        return Qt.hsla(h, s, l, 1.0)
    }

    border.color: contrastColor()
    border.width: csth.pressed ? 8 : 4
    property real penWidth: 5

    TapHandler {
        id: csth
        dragThreshold: 1000
        onTapped: (pt) => {
                      painterItem.setColor(parent.color, pt.id)
                      colorChange(parent.color, pt.id)
                  }
        onLongPressed: {
            painterItem.clear(parent.color)
        }
    }
}

Le site TapHandler réagit aux touches provenant de différents points de contact (identifiés par pt.id), ce qui permet à chaque main d'avoir sa propre couleur. Lorsqu'une couleur est sélectionnée, le signal colorChange met à jour la couleur de la main et la couleur du dessin pour ce point de contact :

y: xrView.referenceSpace === XrView.ReferenceSpaceLocalFloor ? 130 : 0
onColorChange: (col, id) => {
    if (id === 1)
        rightHand.color = col
    else
        leftHand.color = col
}

Ceci démontre comment construire des interfaces multi-utilisateurs ou multi-mains où les différents points de contact peuvent avoir un état et un comportement indépendants.

Contrôles de l'interface utilisateur et personnalisation du modèle

L'exemple comprend un panneau de contrôle complet qui permet aux utilisateurs de :

  • Sélectionner différentes primitives géométriques (tore, cube, sphère, cylindre et cône).
  • Ajuster l'échelle du modèle 3D.
  • Modifier la largeur du stylo pour le dessin.
  • Effacer la toile de dessin en appuyant longuement sur un échantillon de couleur.

Ces commandes montrent comment construire des interfaces spatiales complètes qui combinent des paradigmes d'interface utilisateur 2D avec une interaction 3D dans un environnement XR.

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.