webgl & Three.js中的物体拾取

1,213

1、引子:

在传统的web开发中,由于存在DOM树以及事件捕获冒泡等机制,我们可以很方便的在某个DOM节点上注册事件,并且执行父元素事件代理等一系列操作。但是在webgl的三维世界里,用户使用鼠标或者touch事件,事件接收方是canvas容器,如何将这种点击行为映射到三维世界,就需要借助三维世界的能力了,并且建立从canvas平面容器到三维世界的桥梁,进行所谓的物体拾取

2、基础知识:DOM与NDC坐标转换,相机射线,观察者模式。熟悉这部分知识的同学,可以直接跳过到下面的代码实现。

  • a、DOM坐标和NDC坐标转换:在这里,我们知道DOM坐标的(0,0)点在容器左上角,NDC坐标的(0,0)点在容器中心,需要进行坐标转换。具体定义可以参考下面两个链接

三维坐标变换 屏幕坐标定义

  • b、相机射线: 根据Three.js的官方定义,射线就是用来做物体拾取的机制。相比较于传统的颜色拾取,射线拾取可以识别多个物体,得到先后顺序,在使用上更加方便并且符合人的直觉。  下面看一个官方的code example 

```javascript

const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
function onMouseMove( event ) {
    // 这里实现了DOM坐标系到NDC坐标系的转换
    mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
    mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
}
function render() {
    // 从相机位置,发出一条射线
    raycaster.setFromCamera( mouse, camera );
    // 检测相机发出射线相交的obj列表
    const intersects = raycaster.intersectObjects( scene.children );
    for ( let i = 0; i < intersects.length; i ++ ) {
        // 将相交物体的材质颜色设置为红色
        intersects[ i ].object.material.color.set( 0xff0000 );
    }
    renderer.render( scene, camera );
}
window.addEventListener( 'mousemove', onMouseMove, false );
window.requestAnimationFrame(render);

```

  • c、观察者模式:与dom事件机制类似,观察者模式非常是适合做这种注册-触发机制的。

3、具体实现:

       考虑到每次遍历intersects数组非常的不方便,特别是当场景中有实际上百个Object3D的时候。所以这里我们定义一个全局的对象存储注册事件,然后修改Object3D的原型链,增加onon和off方法,来实现类似于DOM元素的事件注册和销毁。

const globalEvent = {click: {}}
Object.assign(Object3D.prototype, {
    $on(eventType, cb) {
       if(globalEvent.hasOwnProperty(eventType)) {
            globalEvent[eventType][this.id] = {
                object3d: this,
                callback: cb
            };
       } else { // error warn}
    }
    $off(eventType) {
        if (!eventType) throw new Error('')
        if(globalEvent.hasOwnProperty(eventType)) {
            delete globalEvent[eventType][this.id]
        } else {
            throw new Error('')
        }
    }
})
init(camera)
function init(camera, container) {
    let intersectPoint, obj, mouseX, mouseY, clicked;
    const targetObj = globalEvent.click
    const rayCaster = new Raycaster();
    function down(e) {
        obj = null;
        e.preventDefault();
        mouseX = event.clientX;
        mouseY = event.clientY;
        if (!globalEvent.click) return;
        rayCaster.setFromCamera(
            new Vector2(
                (mouseX / window.innerWidth) * 2 - 1,
                -(mouseY / window.innerHeight) * 2 + 1
            ),
            camera
        ); 
        let intersects = rayCaster.intersectsObjects(getVisibleList(targetObj));
        if (intersects.length > 0) {
            if (clicked) {
                obj = null;
                return;
            }
            clicked = true;
            obj = intersects[0].object;
            intersectPoint = intersects[0].point;                        
        } else {
            clicked = false;
        }
    }
    function move(e) {
        event.preventDefault();
        // 这里针对移动端做一些优化 
    }
    function up(e) {
        event.preventDefault();
        if (clicked && !!obj && obj.callback) {
            obj.callback(obj.object3d, intersectPoint);
        }
        clicked = false
    }    const eventOption = {      passive: false    };    container.addEventListener('mousedown', down, {passive: false});    container.addEventListener('mousemove', move, {passive: false});    container.addEventListener('mouseup', up, {passive: false});    container.addEventListener('touchstart', down, {passive: false});    container.addEventListener('touchmove', move, {passive: false});    container.addEventListener('touchend', up, {passive: false});}

function getVisibleList(targetObj) {
    const list = []
    for (const key in targetObj) {
        const target = targetObj[key].object3d;
        if (target.visible) list.push(target);
    }
    return list
}

/*
    使用方式:直接在mesh上注册事件
    target: 命中物体
    point: 命中点的三维坐标
*/
mesh.$on('click', (target, point)) {

}

4、总结

本文通过修改three.js中Object3D的原型链,基于观察者模式,增加了点击事件绑定,支持用户点击canvas容器,拾取场景中的物体。在具体的生产环境使用中,还需要考虑如何实现事件冒泡,渲染层级,鼠标拖拽和长按等操作,这些不在主流程中,不再赘述,大家可以在debug的过程中,逐渐完善上面的代码。

有问题可以联系作者 liyang.peace@bytedance.com

打个广告:字节跳动-产品研发和工程架构部-VR实验室在大量招聘软硬件工程师,涉及算法,嵌入式开发,PCB工程师,前端webgl工程师等,欢迎投递简历至上面的邮箱。

前端webgl工程师的招聘要求:

方向二:web3d开发
1、熟练掌握 JavaScript,WebGL;
2、熟悉计算机图形学,渲染管线/线性代数;
3、熟悉常用 Shader 原理及编写;
4、熟悉至少一款 H5 渲染引擎,如ThreeJS,Babylon等;
5、热爱钻研新技术,有强烈的好奇心和求知欲,有良好的编码规范;
加分项:
1、熟悉VR看房相关业务
2、熟悉 ThreeJS,Babylon, Unity3D 有相关 3D 作品或 DEMO。
3、各大前端技术社区活跃者、有自己的开源项目;

工作间隙写了这篇文章,难免有概念不严谨的地方或者代码错误,希望大家多多指正,我会及时更新完善文章