Qt Quick 3D Physics - Queries Example

// Copyright (C) 2026 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import QtQuick3D
import QtQuick3D.Helpers
import QtQuick3D.Physics

Window {
    id: window
    width: 1280
    height: 720
    visible: true
    title: qsTr("Qt Quick 3D Physics - Queries")

    property int currentMode: 0
    property real playerYaw: 0
    property real cameraPitch: -15

    property bool keyW: false
    property bool keyS: false
    property bool keyA: false
    property bool keyD: false
    property bool keySpace: false

    readonly property var modeDetails: [
        { name: "1. Single Probe", type: "PROBE", desc: "Closest body along a ray (singleRaycastQuery)" },
        { name: "2. Piercing Probe", type: "PROBE", desc: "Every body along a ray (multiRaycastQuery)" },
        { name: "3. Volume Probe", type: "PROBE", desc: "Closest body along a sweep (singleSweepQuery)" },
        { name: "4. Piercing Volume Probe", type: "PROBE", desc: "Every body along a sweep (multiSweepQuery)" },
        { name: "5. Radial Pulse", type: "PROBE", desc: "Every body in a volume (multiOverlapQuery)" },
        { name: "6. Line-of-Sight Scanner", type: "SCANNER", desc: "Is the ray blocked? (testRaycastQuery)" },
        { name: "7. Clearance Scanner", type: "SCANNER", desc: "Is the corridor clear? (testSweepQuery)" },
        { name: "8. Occupancy Scanner", type: "SCANNER", desc: "Is the volume occupied? (testOverlapQuery)" }
    ]

    Timer {
        id: autoFireTimer
        interval: 150
        repeat: true
        onTriggered: {
            if (window.keySpace) {
                window.runQuery();
            } else {
                autoFireTimer.stop();
            }
        }
    }

    function updateShootingState() {
        if (keySpace) {
            if (!autoFireTimer.running) {
                runQuery();
                autoFireTimer.start();
            }
        } else {
            autoFireTimer.stop();
        }
    }

    Item {
        id: inputController
        anchors.fill: parent
        focus: true

        Component.onCompleted: inputController.forceActiveFocus()

        Keys.onPressed: (event) => {
            let k = event.key;
            let t = event.text.toLowerCase();

            if (k === Qt.Key_W || t === "w") window.keyW = true;
            if (k === Qt.Key_S || t === "s") window.keyS = true;
            if (k === Qt.Key_A || t === "a") window.keyA = true;
            if (k === Qt.Key_D || t === "d") window.keyD = true;

            if (k === Qt.Key_Space && !event.isAutoRepeat) {
                window.keySpace = true;
                window.updateShootingState();
            }

            if (k >= Qt.Key_1 && k <= Qt.Key_8) {
                window.currentMode = k - Qt.Key_1;
            }
        }

        Keys.onReleased: (event) => {
            let k = event.key;
            let t = event.text.toLowerCase();

            if (k === Qt.Key_W || t === "w") window.keyW = false;
            if (k === Qt.Key_S || t === "s") window.keyS = false;
            if (k === Qt.Key_A || t === "a") window.keyA = false;
            if (k === Qt.Key_D || t === "d") window.keyD = false;

            if (k === Qt.Key_Space) {
                window.keySpace = false;
                window.updateShootingState();
            }
        }
    }

    View3D {
        id: view
        anchors.fill: parent

        PhysicsWorld {
            id: physicsWorld
            scene: view.scene
            running: true
            gravity: Qt.vector3d(0, -981, 0)
        }

        // Shapes used for Sweep and Overlap queries
        SphereShape { id: querySphereShape; diameter: 200 }
        BoxShape { id: queryBoxShape; extents: Qt.vector3d(200, 200, 200) }

        MouseArea {
            anchors.fill: parent
            acceptedButtons: Qt.LeftButton | Qt.RightButton
            property point lastPos: Qt.point(0, 0)

            onPressed: (mouse) => {
                inputController.forceActiveFocus();
                lastPos = Qt.point(mouse.x, mouse.y);
            }

            onPositionChanged: (mouse) => {
                if (pressedButtons & Qt.RightButton || pressedButtons & Qt.LeftButton) {
                    let dx = mouse.x - lastPos.x;
                    let dy = mouse.y - lastPos.y;

                    window.playerYaw -= dx * 0.25;
                    window.cameraPitch = Math.max(-60, Math.min(30, window.cameraPitch - dy * 0.25));
                    lastPos = Qt.point(mouse.x, mouse.y);
                }
            }

            onWheel: (wheel) => {
                if (wheel.angleDelta.y > 0) {
                    window.currentMode = (window.currentMode + 1) % window.modeDetails.length;
                } else {
                    window.currentMode = (window.currentMode - 1 + window.modeDetails.length)
                                       % window.modeDetails.length;
                }
            }
        }

        environment: SceneEnvironment {
            clearColor: "#0f111a"
            backgroundMode: SceneEnvironment.Color
        }

        CharacterController {
            id: player
            position: Qt.vector3d(0, 150, 1200)
            eulerRotation.y: window.playerYaw
            gravity: physicsWorld.gravity

            collisionShapes: CapsuleShape {
                diameter: 80
                height: 180
            }

            Model {
                source: "#Cylinder"
                scale: Qt.vector3d(0.8, 1.8, 0.8)
                materials: [ PrincipledMaterial { baseColor: "#00d2d3" } ]
            }

            Node {
                id: cameraRig
                eulerRotation.x: window.cameraPitch

                PerspectiveCamera {
                    id: camera
                    position: Qt.vector3d(0, 200, 500)
                    eulerRotation.x: -10
                    clipNear: 1
                }
            }
        }

        DirectionalLight {
            eulerRotation: Qt.vector3d(-45, 30, 0)
            brightness: 1.5
        }

        StaticRigidBody {
            position: Qt.vector3d(0, -50, 0)
            collisionShapes: BoxShape { extents: Qt.vector3d(6000, 100, 6000) }
            Model {
                source: "#Cube"
                scale: Qt.vector3d(60, 1, 60)
                materials: [ PrincipledMaterial { baseColor: "#1e272e" } ]
            }
        }

        component ArenaWall : StaticRigidBody {
            property vector3d wallSize: Qt.vector3d(100, 100, 100)
            collisionShapes: BoxShape { extents: wallSize }
            Model {
                source: "#Cube"
                scale: Qt.vector3d(wallSize.x * 0.01, wallSize.y * 0.01, wallSize.z * 0.01)
                materials: [ PrincipledMaterial { baseColor: "#34495e" } ]
            }
        }

        ArenaWall { position: Qt.vector3d(0, 300, -3000); wallSize: Qt.vector3d(6000, 600, 200) }
        ArenaWall { position: Qt.vector3d(0, 300, 3000); wallSize: Qt.vector3d(6000, 600, 200) }
        ArenaWall { position: Qt.vector3d(-3000, 300, 0); wallSize: Qt.vector3d(200, 600, 6000) }
        ArenaWall { position: Qt.vector3d(3000, 300, 0); wallSize: Qt.vector3d(200, 600, 6000) }

        component DynamicCube : DynamicRigidBody {
            id: cubeRoot
            property color cubeColor: "#e67e22"
            collisionShapes: BoxShape { extents: Qt.vector3d(100, 100, 100) }
            Model {
                source: "#Cube"
                scale: Qt.vector3d(1, 1, 1)
                materials: [ PrincipledMaterial { baseColor: cubeRoot.cubeColor } ]
            }
        }

        component DynamicSphere : DynamicRigidBody {
            id: sphereRoot
            property color sphereColor: "#1abc9c"
            collisionShapes: SphereShape { diameter: 100 }
            Model {
                source: "#Sphere"
                scale: Qt.vector3d(1, 1, 1)
                materials: [ PrincipledMaterial { baseColor: sphereRoot.sphereColor } ]
            }
        }

        // --- MASSIVE DYNAMIC OBJECTS GENERATOR (500+ BODIES) ---
        Component { id: dynamicCubeComp; DynamicCube {} }
        Component { id: dynamicSphereComp; DynamicSphere {} }

        Component.onCompleted: {
            let colors = ["#e74c3c", "#e67e22", "#f1c40f", "#2ecc71", "#3498db", "#9b59b6", "#1abc9c", "#e84393"];

            // Generate a huge wall of dynamic objects (18 x 7 x 4 grid = ~500 objects)
            for (let x = -1000; x <= 1000; x += 120) {
                for (let y = 50; y <= 850; y += 120) {
                    for (let z = -1000; z >= -1400; z -= 120) {
                        let isSphere = (Math.random() > 0.5);
                        let comp = isSphere ? dynamicSphereComp : dynamicCubeComp;
                        let randomColor = colors[Math.floor(Math.random() * colors.length)];

                        comp.createObject(view.scene, {
                            "position": Qt.vector3d(x + (Math.random() * 5), y, z + (Math.random() * 5)),
                            "cubeColor": randomColor,
                            "sphereColor": randomColor
                        });
                    }
                }
            }
        }

        Node {
            id: tracerBeam
            visible: false
            Model {
                id: tracerMesh
                source: "#Cylinder"
                scale: Qt.vector3d(1, 1, 1)
                materials: [
                    PrincipledMaterial {
                        id: tracerMat
                        baseColor: "#f1c40f"
                        opacity: 0.5
                        alphaMode: PrincipledMaterial.Blend
                        lighting: PrincipledMaterial.NoLighting
                    }
                ]
            }
        }
        Timer { id: tracerTimer; interval: 150; onTriggered: tracerBeam.visible = false }

        Model {
            id: volumeGhost
            visible: false
            materials: [
                PrincipledMaterial {
                    id: ghostMat
                    baseColor: "#3300ff00"
                    alphaMode: PrincipledMaterial.Blend
                    opacity: 0.4
                    lighting: PrincipledMaterial.NoLighting
                }
            ]
        }
    }

    FrameAnimation {
        running: true
        onTriggered: {
            let speed = 800;
            let moveX = 0;
            let moveZ = 0;

            if (window.keyW) moveZ -= 1;
            if (window.keyS) moveZ += 1;
            if (window.keyA) moveX -= 1;
            if (window.keyD) moveX += 1;

            let len = Math.sqrt(moveX * moveX + moveZ * moveZ);
            if (len > 0) {
                moveX /= len;
                moveZ /= len;
                player.movement = Qt.vector3d(moveX * speed, 0, moveZ * speed);
            } else {
                player.movement = Qt.vector3d(0, 0, 0);
            }

            let rad = player.eulerRotation.y * Math.PI / 180.0;
            let forwardWorld = Qt.vector3d(-Math.sin(rad), 0, -Math.cos(rad)).normalized();

            window.updateScannerState(forwardWorld);
        }
    }

    // The player's own collider is an ordinary hit candidate, so a query starting at
    // the character's center would hit its own capsule at distance 0. Start the query
    // slightly above and in front of the capsule instead.
    function probeOrigin(forwardDir) {
        return player.position.plus(Qt.vector3d(0, 50, 0)).plus(forwardDir.times(150));
    }

    // Only a DynamicRigidBody can take an impulse. The queries here also return the
    // static arena and the player's own character controller.
    function pushBody(body, impulse) {
        if (body instanceof DynamicRigidBody)
            body.applyCentralImpulse(impulse);
    }

    function runQuery() {
        if (currentMode >= 5) {
            statusText.text = "Scanner mode: modes 1-5 are triggered manually";
            return;
        }

        let rad = player.eulerRotation.y * Math.PI / 180.0;
        let pitchRad = cameraRig.eulerRotation.x * Math.PI / 180.0;

        let forwardDir = Qt.vector3d(
            -Math.sin(rad) * Math.cos(pitchRad),
            Math.sin(pitchRad),
            -Math.cos(rad) * Math.cos(pitchRad)
        ).normalized();

        let origin = window.probeOrigin(forwardDir);
        let maxDist = 3000;

        switch (currentMode) {

        case 0: // singleRaycastQuery
            let hit1 = physicsWorld.singleRaycastQuery(origin, forwardDir, maxDist, true, true);

            // hit.body === null is the miss test; distance is 0 on a miss.
            let dist1 = hit1.body ? hit1.distance : maxDist;
            renderTracer(origin, forwardDir, dist1, "#f1c40f", 10, 10, false);

            if (hit1.body) {
                window.pushBody(hit1.body, forwardDir.times(250000));
                statusText.text = "singleRaycastQuery: hit at " + Math.round(hit1.distance) + " cm";
            } else {
                statusText.text = "singleRaycastQuery: missed";
            }
            break;

        case 1: // multiRaycastQuery
            let hits2 = physicsWorld.multiRaycastQuery(origin, forwardDir, maxDist, true, true);
            renderTracer(origin, forwardDir, maxDist, "#3498db", 10, 10, false);

            for (let i = 0; i < hits2.length; ++i)
                window.pushBody(hits2[i].body, forwardDir.times(200000));

            statusText.text = "multiRaycastQuery: hit count " + hits2.length;
            break;

        case 2: // singleSweepQuery
            querySphereShape.position = origin;
            let hit3 = physicsWorld.singleSweepQuery(querySphereShape, forwardDir, maxDist, true, true);

            // A sweep that starts already overlapping a body reports a negative
            // distance, so test body rather than distance to detect a miss.
            let dist3 = hit3.body ? Math.max(hit3.distance, 0) : maxDist;

            // Cylinder matching the swept SphereShape diameter
            renderTracer(origin, forwardDir, dist3, "#e74c3c", 200, 200, false);

            if (hit3.body) {
                window.pushBody(hit3.body, forwardDir.times(300000));
                statusText.text = "singleSweepQuery: hit at " + Math.round(hit3.distance) + " cm";
            } else {
                statusText.text = "singleSweepQuery: missed";
            }
            break;

        case 3: // multiSweepQuery
            queryBoxShape.position = origin;
            let hits4 = physicsWorld.multiSweepQuery(queryBoxShape, forwardDir, maxDist, true, true);

            // Translucent box corridor along the sweep trajectory
            renderTracer(origin, forwardDir, maxDist, "#9b59b6", 200, 200, true);

            for (let j = 0; j < hits4.length; ++j)
                window.pushBody(hits4[j].body, forwardDir.times(250000));

            statusText.text = "multiSweepQuery: hit count " + hits4.length;
            break;

        case 4: // multiOverlapQuery
            let pulseCenter = origin.plus(forwardDir.times(600));
            querySphereShape.position = pulseCenter;

            let overlapHits = physicsWorld.multiOverlapQuery(querySphereShape, true, true);

            volumeGhost.source = "#Sphere";
            volumeGhost.position = pulseCenter;
            volumeGhost.scale = Qt.vector3d(2, 2, 2);
            ghostMat.baseColor = "#e74c3c";
            volumeGhost.visible = true;

            for (let k = 0; k < overlapHits.length; ++k) {
                let body5 = overlapHits[k].body;
                if (!body5)
                    continue;
                let pushDir = body5.position.minus(pulseCenter);
                pushDir = pushDir.length() > 0 ? pushDir.normalized() : Qt.vector3d(0, 1, 0);
                window.pushBody(body5, pushDir.times(350000).plus(Qt.vector3d(0, 100000, 0)));
            }
            statusText.text = "multiOverlapQuery: hit count " + overlapHits.length;
            break;
        }
    }

    function updateScannerState(forwardDir) {
        if (currentMode < 5) {
            if (currentMode !== 4) volumeGhost.visible = false;
            return;
        }

        let origin = window.probeOrigin(forwardDir);
        let scanDist = 1200;

        switch (currentMode) {

        case 5: // testRaycastQuery
            let isBlocked5 = physicsWorld.testRaycastQuery(origin, forwardDir, scanDist, true, true);
            renderTracer(origin, forwardDir, scanDist, isBlocked5 ? "#ff2d55" : "#00ff66", 10, 10, false);

            statusText.text = isBlocked5 ? "testRaycastQuery: blocked" : "testRaycastQuery: path clear";
            statusText.color = isBlocked5 ? "#ff2d55" : "#00ff66";
            break;

        case 6: // testSweepQuery
            querySphereShape.position = origin;
            let isBlocked6 = physicsWorld.testSweepQuery(querySphereShape, forwardDir, scanDist, true, true);

            volumeGhost.source = "#Sphere";
            volumeGhost.position = origin.plus(forwardDir.times(scanDist));
            volumeGhost.scale = Qt.vector3d(2, 2, 2);
            ghostMat.baseColor = isBlocked6 ? "#ff2d55" : "#00ff66";
            volumeGhost.visible = true;

            statusText.text = isBlocked6 ? "testSweepQuery: corridor blocked" : "testSweepQuery: corridor clear";
            statusText.color = isBlocked6 ? "#ff2d55" : "#00ff66";
            break;

        case 7: // testOverlapQuery
            let checkZone = origin.plus(forwardDir.times(500));
            querySphereShape.position = checkZone;
            let isOccupied = physicsWorld.testOverlapQuery(querySphereShape, true, true);

            volumeGhost.source = "#Sphere";
            volumeGhost.position = checkZone;
            volumeGhost.scale = Qt.vector3d(2, 2, 2);
            ghostMat.baseColor = isOccupied ? "#ff2d55" : "#00ff66";
            volumeGhost.visible = true;

            statusText.text = isOccupied ? "testOverlapQuery: volume occupied" : "testOverlapQuery: volume empty";
            statusText.color = isOccupied ? "#ff2d55" : "#00ff66";
            break;
        }
    }

    function renderTracer(origin, forwardDir, dist, colorHex, thicknessX, thicknessY, isBox) {
        thicknessX = thicknessX !== undefined ? thicknessX : 10;
        thicknessY = thicknessY !== undefined ? thicknessY : 10;
        isBox = isBox !== undefined ? isBox : false;

        tracerMesh.source = isBox ? "#Cube" : "#Cylinder";
        tracerBeam.position = origin.plus(forwardDir.times(dist / 2));
        tracerBeam.eulerRotation = Qt.vector3d(cameraRig.eulerRotation.x, player.eulerRotation.y, 0);

        tracerMesh.scale = Qt.vector3d(thicknessX * 0.01, thicknessY * 0.01, dist * 0.01);
        tracerMat.baseColor = colorHex;
        tracerMat.opacity = (thicknessX > 50) ? 0.35 : 0.8;

        tracerBeam.visible = true;
        tracerTimer.restart();
    }

    ColumnLayout {
        anchors.left: parent.left
        anchors.top: parent.top
        anchors.margins: 15
        spacing: 10

        Rectangle {
            implicitWidth: 420
            implicitHeight: 200
            color: "#d910141d"
            radius: 8
            border.color: "#34495e"
            border.width: 1

            ColumnLayout {
                anchors.fill: parent
                anchors.margins: 12

                Text {
                    text: "PHYSICS SCENE QUERIES"
                    color: "white"
                    font.bold: true
                    font.pixelSize: 14
                }

                ComboBox {
                    id: modeSelect
                    Layout.fillWidth: true
                    model: window.modeDetails.map(item => "[" + item.type + "] " + item.name)
                    currentIndex: window.currentMode
                    focusPolicy: Qt.NoFocus
                    onActivated: (index) => {
                        window.currentMode = index;
                        inputController.forceActiveFocus();
                    }
                }

                Text {
                    text: window.modeDetails[window.currentMode].desc
                    color: "#f1c40f"
                    font.pixelSize: 12
                    font.italic: true
                }

                Text {
                    text: "Controls:"
                          + "\n- WASD: Move character"
                          + "\n- Hold Mouse Drag: Rotate camera"
                          + "\n- Spacebar: Run the selected query"
                          + "\n- Keys 1-8: Switch query modes"
                    color: "#bdc3c7"
                    font.pixelSize: 11
                }

                Rectangle {
                    Layout.fillWidth: true
                    implicitHeight: 34
                    color: "#000000"
                    radius: 4

                    Text {
                        id: statusText
                        anchors.centerIn: parent
                        color: "#00ff66"
                        font.pixelSize: 11
                        font.bold: true
                    }
                }
            }
        }
    }
}