En esta página

Qt Quick 3D - XR Tacto avanzado

Demuestra el tacto en pantallas curvas y el manejo táctil de modelos 3D.

Ejemplo de panel de interfaz curvo y objetos 3D pintables

Este ejemplo se basa en el ejemplo XR Simple Touch para demostrar técnicas más avanzadas de interacción táctil en Qt Quick 3D Xr:

  • Interacción táctil con elementos de interfaz de usuario 2D renderizados en una superficie 3D curva.
  • Manejo táctil directo en modelos 3D utilizando coordenadas UV.
  • Agarre y manipulación de objetos 3D mediante gestos de pellizco.
  • Creación de mallas curvas procedurales para pantallas inmersivas.
  • Creación de texturas personalizadas que se pueden pintar con C++.

Pantalla curva con elemento fuente

Una de las características clave de este ejemplo es la visualización de una interfaz de usuario 2D en una superficie curva. Creamos un componente de malla procedural personalizado, CurvedMesh, que genera un plano curvo:

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

CurvedMesh hereda de ProceduralMesh y genera vértices, normales y coordenadas UV para una superficie curva. La curvatura se controla mediante la propiedad radius, que determina cuánto se curva la superficie. La malla se divide en segmentos de partitions para crear una curva suave.

A continuación, aplicamos esta geometría curva a un Model con un PrincipledMaterial que utiliza un sourceItem para renderizar contenido 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 propiedad sourceItem contiene un Qt Quick estándar Rectangle con una interfaz de usuario compleja que incluye muestras de color, controles deslizantes y un lienzo de dibujo. Este contenido 2D se proyecta automáticamente sobre la superficie 3D curva. Los eventos táctiles en la superficie curva se vuelven a mapear a coordenadas 2D y se envían a los elementos Qt Quick apropiados.

Nota: Los elementos emissiveMap y emissiveFactor se utilizan para hacer que la pantalla parezca autoiluminada, mejorando la visibilidad en diferentes condiciones de iluminación. La ligera transparencia (opacidad: 0,99) permite la mezcla alfa.

Textura pintable personalizada

El lienzo de dibujo de la interfaz de usuario utiliza una clase C++ personalizada, TextureItem, que amplía QQuickPaintedItem para proporcionar una textura pintable que puede utilizarse en materiales 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;
        }
    };

TextureItem mantiene un QImage interno sobre el que se puede dibujar utilizando QPainter. Los datos de la imagen se exponen entonces como un QQuick3DTextureData que puede utilizarse en texturas de materiales. Esto nos permite crear texturas dinámicas modificadas por el usuario que se actualizan en tiempo real.

El método clave es setUv(), que maneja la entrada táctil en el espacio de coordenadas 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;
    }

Este método implementa varias características sofisticadas:

  • Suavizado temporal con filtrado adaptativo para reducir las fluctuaciones.
  • Detección y gestión de la envoltura de coordenadas UV (cuando el dibujo cruza los límites de la textura).
  • Seguimiento del estado de cada punto táctil para soporte multitáctil.

Manejo táctil de modelos 3D

El ejemplo muestra la interacción táctil directa con modelos 3D a través del componente mejorado XrGadget. Cuando una mano toca un modelo 3D, la posición del toque se convierte en coordenadas UV y se envía al modelo:

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

El componente XrGadget recibe estos eventos táctiles a través de su función handleTouch:

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

Cuando el usuario no está agarrando el modelo, los eventos táctiles se reenvían a TextureItem, que dibuja sobre la textura del modelo. Esto permite a los usuarios pintar directamente sobre la superficie del objeto 3D.

Agarrar y manipular objetos 3D

El ejemplo también implementa la funcionalidad de agarrar y mover mediante el gesto de pellizcar:

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

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

Cuando el usuario realiza un gesto de pellizco con la mano derecha, el sistema captura el desplazamiento y la rotación entre la mano y el objeto seleccionado:

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

Cuando la mano se mueve, el objeto la sigue manteniendo el desplazamiento original y la rotación relativa. Esto crea una forma natural e intuitiva de posicionar objetos 3D en el espacio.

Selección de color y multitoque

La paleta de colores de la pantalla curva demuestra la interacción multitáctil. Cada mano puede seleccionar un color de forma independiente tocando una muestra de color:

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

TapHandler responde a las pulsaciones desde distintos puntos táctiles (identificados por pt.id), lo que permite que cada mano tenga su propio color. Cuando se selecciona un color, se actualiza tanto el color de la mano como el color del dibujo para ese identificador táctil a través de la señal colorChange:

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

Esto demuestra cómo construir interfaces multiusuario o multimano donde diferentes puntos táctiles pueden tener estado y comportamiento independientes.

Controles de interfaz de usuario y personalización del modelo

El ejemplo incluye un panel de control completo que permite a los usuarios:

  • Seleccionar diferentes primitivas geométricas (toroide, cubo, esfera, cilindro y cono).
  • Ajustar la escala del modelo 3D.
  • Cambiar la anchura del lápiz para dibujar.
  • Borrar el lienzo de dibujo pulsando prolongadamente una muestra de color.

Estos controles demuestran cómo construir interfaces espaciales completas que combinan paradigmas de interfaz de usuario 2D con interacción 3D en un entorno XR.

Proyecto de ejemplo @ 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.