Bootstrap

Three.js - 通过 THREE.Raycaster 实现模型选中与信息显示

示例浏览地址:https://ithanmang.gitee.io/threejs/home/201807/20180703/02-raycasterDemo.html
双击鼠标左键选中模型并显示信息。

首先,解释一下三种坐标系的概念:场景坐标系(世界坐标系)、屏幕坐标系、视点坐标系。
场景坐标
通过three.js构建出来的场景,都具有一个固定不变的坐标系(无论相机的位置在哪),并且放置的任何物体都要以这个坐标系来确定自己的位置,也就是(0,0, 0)坐标。例如我们创建一个场景并添加箭头辅助。
这里写图片描述
屏幕坐标
在显示屏上的坐标就是屏幕坐标系。
如下图所示,其中的clientXclientY的最值由,window.innerWidth,window.innerHeight决定。
这里写图片描述
视点坐标
视点坐标系就是以相机的中心点为原点,但是相机的位置,也是根据世界坐标系来偏移的,webGL会将世界坐标先变换到视点坐标,然后进行裁剪,只有在视线范围(视见体)之内的场景才会进入下一阶段的计算
如下图添加了相机辅助线.
这里写图片描述

射线检测

若想获取鼠标点击的物体,name就需要把屏幕坐标系转换为three.js中的三维坐标系。
three.js提供了一个类THREE.Raycaster可以用来解决这个问题。
看个示例图
这里写图片描述

THREE.Raycaster

THREE.Raycaster对象从屏幕上的点击位置向场景中发射一束光线。

// 计算出鼠标经过的3d空间中的对象
Raycaster( origin, direction, near, far ) { }
1 参数
origin — 射线的起点向量。
direction — 射线的方向向量,应该归一化。
near — 所有返回的结果应该比 near 远。Near不能为负,默认值为0。
far — 所有返回的结果应该比 far 近。Far 不能小于 near,默认值为无穷大。
2 方法
1 setFromCamera

用新的原点和方向来更新射线

方法名
.setFromCamera(coords : Vector2, camera : Camera ) : null
参数

coords - 鼠标的二维坐标,在归一化的设备坐标(NDC)中,也就是X 和 Y 分量应该介于 -1 和 1 之间。
camera - 射线起点处的相机,即把射线起点设置在该相机位置处。

**2 intersectObject **

来判断指定对象有没有被这束光线击中,返回被击中对象的信息,相交的结果会以一个数组的形式返回,其中的元素依照距离排序,越近的排在越前。

方法名
.intersectObject ( object, recursive : Boolean, optionalTarget : Array ) : Array
参数

object - 检测与射线相交的物体
recursive- 若为 true 则检查后代对象,默认值为false
optionalTarget - (可选参数)用来设置方法返回的设置结果。若不设置则返回一个实例化的数组。如果设置,必须在每次调用之前清除这个数组(例如,array.length= 0;)

返回值 Array

这里写图片描述
[ { distance, point, face, faceIndex, object }, … ]
distance - 射线的起点到相交点的距离
point - 在世界坐标中的交叉点
face -相交的面
faceIndex - 相交的面的索引
object - 相交的对象
uv - 交点的二维坐标

当计算这个对象是否和射线相交时,Raycaster 把传递的对象委托给 raycast 方法。 这允许 meshes 对于光线投射的响应可以不同于 lines 和 pointclouds.


注意,对于网格,面(faces)必须朝向射线原点,这样才能被检测到;通过背面的射线的交叉点将不被检测到。 为了光线投射一个对象的正反两面,你得设置 material 的 side 属性为 THREE.DoubleSide

**3 intersectObjects **

intersectObjects 与 intersectObject 类似,除了传入的参数是一个数组之外,并无大的差别。

方法名
.intersectObjects ( objects : Array, recursive : Boolean, optionalTarget : Array ) : Array

objects - 传入的参数。

3 主要代码
// 获取与射线相交的对象数组
function getIntersects(event) {
    event.preventDefault();
    console.log("event.clientX:"+event.clientX)
    console.log("event.clientY:"+event.clientY)

    // 声明 raycaster 和 mouse 变量
    var raycaster = new THREE.Raycaster();
    var mouse = new THREE.Vector2();

    // 通过鼠标点击位置,计算出 raycaster 所需点的位置,以屏幕为中心点,范围 -1 到 1
    mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

    //通过鼠标点击的位置(二维坐标)和当前相机的矩阵计算出射线位置
    raycaster.setFromCamera(mouse, camera);

    // 获取与raycaster射线相交的数组集合,其中的元素按照距离排序,越近的越靠前
    var intersects = raycaster.intersectObjects(scene.children);

    //返回选中的对象数组
    return intersects;
}
导入外部模型注意事项
// 获取与raycaster射线相交的数组集合,其中的元素按照距离排序,越近的越靠前
var intersects = raycaster.intersectObjects(scene.children);

上面的raycaster.intersectObjects()的参数是scene.children,因为这里是测试的模型,没有涉及到外部模型的导入,但是再开发的时候我们一般都是对外部模型进行处理。
首先,你通过加载器把模型加载到场景中的时候需要在回调函数中打印一下加载进来的是一个什么对象,有可能是一个Mesh或者Group当然大部分模型资源基本上都是Group但是不排除还有别的类型例如SceneObject
此时,我们不能盲目的去直接把整个scene.children中的东西都放到raycaster.intersectObjects()来直接进行检测,因为整个scene.children中可能有一另一个scene或者是three.js不能识别的对象,所以我们需要先对加载进来的对象进行处理;
最好是先创建一个组对象new THREE.Group(),然后用这个组里面的对象来进行射线检测;
在这里插入图片描述
看下上面方法的第一个参数,是一个 ArrayGroup.children也是一个数组,所以我们可以把需要进行射线检测的物体放进一个组对象里面,便于处理;

需要注意的是:如上截图该方法的第二个参数recursive,若为true,则同时也会检查所有的后代,否则将只会检查对象本身,该参数默认值为false,如果你传入了一个scene对象,把这个参数设置为true,来进行后代对象检测

raycaster.intersectObjects(group.children);
元素按照距离排序,越近的越靠前

这句话的意思是,首先,点击或者触发方法创建THREE.Raycaster()对象,然后从点击位置,发出一条射线,先被射线穿过的对象,会在数组中排序靠前。
例如我们从y轴对着球点击,然后看一下返回的数组:
这里写图片描述
这里写图片描述
返回了三个Mesh对象,因为这三个物体同时被从鼠标点击处发出的射线给穿透,因此都被返回,而球几何体离点击的位置最近,所以第一个元素就是球体。

4 动态创建DIV

部分代码

// 更新div的位置
 function renderDiv(object) {
     // 获取窗口的一半高度和宽度
     var halfWidth = window.innerWidth / 2;
     var halfHeight = window.innerHeight / 2;

     // 逆转相机求出二维坐标
     var vector = object.position.clone().project(camera);

     // 修改 div 的位置
     $("#label").css({
         left: vector.x * halfWidth + halfWidth,
         top: -vector.y * halfHeight + halfHeight - object.position.y
     });
     // 显示模型信息
     $("#label").text("name:" + object.name);
 }

这需要将场景坐标,转换成二维屏幕坐标。
首先,我们需要得到当前点在世界中的坐标位置,如果是某个场景组Group里面的模型的位置坐标那种,我们可以通过模型的方法localToWorld方法获取到世界坐标。
localToWorld方法名

.localToWorld ( vector : Vector3 ) : Vector3

作用:将矢量从本地空间坐标转换为世界坐标。\

求出二维坐标
// 逆转相机求出二维坐标
var vector = object.position.clone().project(camera);
修改DIV的位置

通过求出的二维坐标,来计算位置。

left: vector.x * halfWidth + halfWidth,
top: -vector.y * halfHeight + halfHeight - object.position.y
5 示例完整代码
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>点击事件</title>
    <style>
        body {
            margin: 0;
            overflow: hidden;
        }

        #label {
            position: absolute;
            padding: 10px;
            background: rgba(255, 255, 255, 0.6);
            line-height: 1;
            border-radius: 5px;
        }
    </style>
    <script src="../../libs/build/three.js"></script>
    <script src="../../libs/jquery-1.9.1.js"></script>
    <script src="../../libs/examples/js/Detector.js"></script>
    <script src="../../libs/examples/js/controls/TrackballControls.js"></script>
    <script src="../../libs/examples/js/libs/dat.gui.min.js"></script>
    <script src="../../libs/examples/js/libs/stats.min.js"></script>
</head>
<body>
<div id="WebGL-output"></div>
<div id="Stats-output"></div>

<div id="label"></div>
<script>

    var stats = initStats();
    var scene, camera, renderer, controls, light, selectObject;

    // 场景
    function initScene() {
        scene = new THREE.Scene();
    }

    // 相机
    function initCamera() {
        camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 10000);
        camera.position.set(0, 400, 600);
        camera.lookAt(new THREE.Vector3(0, 0, 0));
    }

    // 渲染器
    function initRenderer() {
        if (Detector.webgl) {
            renderer = new THREE.WebGLRenderer({antialias: true});
        } else {
            renderer = new THREE.CanvasRenderer();
        }
        renderer.setSize(window.innerWidth, window.innerHeight);
        renderer.setClearColor(0x050505);
        document.body.appendChild(renderer.domElement);
    }

    // 初始化模型
    function initContent() {
        var helper = new THREE.GridHelper(1200, 50, 0xCD3700, 0x4A4A4A);
        scene.add(helper);

        var cubeGeometry = new THREE.BoxGeometry(100, 100, 100);
        var cubeMaterial = new THREE.MeshLambertMaterial({color: 0x9370DB});
        var cube = new THREE.Mesh(cubeGeometry, cubeMaterial);
        cube.position.y = 50;
        cube.name = "cube";
        scene.add(cube);

        var sphereGeometry = new THREE.SphereGeometry(50, 50, 50, 50);
        var sphereMaterial = new THREE.MeshLambertMaterial({color: 0x3CB371});
        var sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
        sphere.position.x = 200;
        sphere.position.y = 50;
        sphere.name = "sphere";
        // sphere.position.z = 200;
        scene.add(sphere);

        var cylinderGeometry = new THREE.CylinderGeometry(50, 50, 100, 100);
        var cylinderMaterial = new THREE.MeshLambertMaterial({color: 0xCD7054});
        var cylinder = new THREE.Mesh(cylinderGeometry, cylinderMaterial);
        cylinder.position.x = -200;
        cylinder.position.y = 50;
        cylinder.name = "cylinder";
        // cylinder.position.z = -200;
        scene.add(cylinder);
    }

    // 鼠标双击触发的方法
    function onMouseDblclick(event) {

        // 获取 raycaster 和所有模型相交的数组,其中的元素按照距离排序,越近的越靠前
        var intersects = getIntersects(event);

        // 获取选中最近的 Mesh 对象
        if (intersects.length != 0 && intersects[0].object instanceof THREE.Mesh) {
            selectObject = intersects[0].object;
            changeMaterial(selectObject);
        } else {
            alert("未选中 Mesh!");
        }
    }

    // 获取与射线相交的对象数组
    function getIntersects(event) {
        event.preventDefault();
        console.log("event.clientX:"+event.clientX)
        console.log("event.clientY:"+event.clientY)

        // 声明 raycaster 和 mouse 变量
        var raycaster = new THREE.Raycaster();
        var mouse = new THREE.Vector2();

        // 通过鼠标点击位置,计算出 raycaster 所需点的位置,以屏幕为中心点,范围 -1 到 1
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;

        //通过鼠标点击的位置(二维坐标)和当前相机的矩阵计算出射线位置
        raycaster.setFromCamera(mouse, camera);

        // 获取与射线相交的对象数组,其中的元素按照距离排序,越近的越靠前
        var intersects = raycaster.intersectObjects(scene.children);

        //返回选中的对象
        return intersects;
    }

    // 窗口变动触发的方法
    function onWindowResize() {
        camera.aspect = window.innerWidth / window.innerHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(window.innerWidth, window.innerHeight);
    }

    // 键盘按下触发的方法
    function onKeyDown(event) {
        switch (event.keyCode) {
            case 13:
                initCamera();
                initControls();
                break;
        }
    }

    // 改变对象材质属性
    function changeMaterial(object) {

        var material = new THREE.MeshLambertMaterial({
            color: 0xffffff * Math.random(),
            transparent: object.material.transparent ? false : true,
            opacity: 0.8
        });
        object.material = material;
    }

    // 初始化轨迹球控件
    function initControls() {
        controls = new THREE.TrackballControls(camera, renderer.domElement);
        // controls.noRotate = true;
        controls.noPan = true;
    }

    // 初始化灯光
    function initLight() {
        light = new THREE.SpotLight(0xffffff);
        light.position.set(-300, 600, -400);
        light.castShadow = true;

        scene.add(light);
        scene.add(new THREE.AmbientLight(0x5C5C5C));
    }

    // 初始化 dat.GUI
    function initGui() {
        // 保存需要修改相关数据的对象
        gui = new function () {

        }
        // 属性添加到控件
        var guiControls = new dat.GUI();
    }

    // 初始化性能插件
    function initStats() {
        var stats = new Stats();

        stats.domElement.style.position = 'absolute';
        stats.domElement.style.left = '0px';
        stats.domElement.style.top = '0px';

        document.body.appendChild(stats.domElement);
        return stats;
    }

    // 更新div的位置
    function renderDiv(object) {
        // 获取窗口的一半高度和宽度
        var halfWidth = window.innerWidth / 2;
        var halfHeight = window.innerHeight / 2;

        // 逆转相机求出二维坐标
        var vector = object.position.clone().project(camera);

        // 修改 div 的位置
        $("#label").css({
            left: vector.x * halfWidth + halfWidth,
            top: -vector.y * halfHeight + halfHeight - object.position.y
        });
        // 显示模型信息
        $("#label").text("name:" + object.name);
    }

    // 更新控件
    function update() {
        stats.update();
        controls.update();
        controls.handleResize();
    }

    // 初始化
    function init() {
        initScene();
        initCamera();
        initRenderer();
        initContent();
        initLight();
        initControls();
        initGui();
        addEventListener('dblclick', onMouseDblclick, false);
        addEventListener('resize', onWindowResize, false);
        addEventListener('keydown', onKeyDown, false);
    }

    function animate() {
        if (selectObject != undefined && selectObject != null) {
            renderDiv(selectObject);
        }
        requestAnimationFrame(animate);
        renderer.render(scene, camera);
        update();
    }

    init();
    animate();

</script>
</body>
</html>
;