WebVR开发教程——交互事件(三)Cardboard与gaze

Cardboard与gaze注视

Cardboard可以说是手机VR头显的元老了,狭义上指的是Google推出的一个带有双凸透镜的盒子,广义上则表示智能手机+盒子的VR体验平台。

cardboard

它的交互方式较为简单,利用了手机的陀螺仪,采用gaze注视行为来触发场景里的事件,比如用户在虚拟商店中注视一款商品时,弹出这个商品的价格信息。

gaze交互

注视事件是WebVR最基本的交互方式,用户通过头部运动改变视线朝向,当用户视线正对着物体时,触发物体绑定的事件,具体分为三个基本事件,分别是gazeEnter,gazeTrigger,gazeLeave
我们可以设置一个位于相机中心的准心来描述这三个基本事件(准确的说,在VR模式下是两个,分别位于左右相机的中心)

  • gazeEnter:当准心进入物体时,即用户注视了物体,触发一次
  • gazeLeave:当准心离开物体时,即用户停止注视该物体时,触发一次
  • gazeTrigger:当准心处于物体时触发,不同于gazeEnter,gazeTrigger会在每一帧刷触发,直到准心离开物体

注视事件原理

注视事件触发条件其实就是物体被用户视线“击中”。在每帧动画渲染中,从准心处沿z轴负方向发出射线,如果射线与物体相交,即物体被射线击中,说明前方的物体被用户注视,这里使用Three提供的raycaster对象,对场景里的3d物体进行射线拾取。

下面是使用THREE.Raycaster拾取物体的简单例子:

// 创建射线发射器实例raycaster
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(origin,camera); // 设置射线源点
raycaster.intersectObjects(targetList); // 检测targetList的object物体是否与射线相交
if (intersects.length > 0) {
    // 获取从源点触发,与射线相交的首个物体
    const target = intersects[0].object;
    // TODO
}

主要分为三步:

  1. new THREE.Raycaster()创建一个射线发射器;
  2. 调用.setFromCamera(origin,camera)设置射线发射源位置,第一个参数origin传入NDC标准化设备坐标,即归一化的屏幕坐标,第二个参数传入相机,此时射线将在屏幕的origin处,沿垂直于相机的近切面的方向进行投射;
  3. 调用.intersectObjects(targetList)检测targetList的物体是否相交
    Raycaster借鉴了光线投射法进行物体拾取,更多用法可参考three.js官方文档

gazeEnter, gazeLeave, gazeTrigger实现

根据上文对gaze基本事件的描述,现在开始创建注视监听器Gazer类,提供事件绑定on、解绑off、更新update的公用方法,物体可注册gazeEnter,gazeLeave,gazeTrigger事件回调,以下是完整代码。

// 注视事件监听器
class Gazer {
    constructor() {
        // 初始化射线发射源
        this.raycaster = new THREE.Raycaster();
        this._center = new THREE.Vector2();
        this.rayList = {},this.targetList = [];
        this._lastTarget = null;
    }
    /** 物体绑定gaze事件的公用方法
     * @param {THREE.Object3D} target 监听的3d网格
     * @param {String} eventType 事件类型 
     * @param {Function} callback 事件回调
     **/
    on(target, eventType, callback) {
        const noop = () => {};
        // target首次绑定事件,则创建监听对象,加入raylist监听列表,并将三个基本事件的回调初始为空方法
        if (!this.rayList[target.id]) this.rayList[target.id] = { target, gazeEnter: noop, gazeTrigger: noop, gazeLeave: noop };
        // 根据传入的 eventType与callback更新事件回调
        this.rayList[target.id][eventType] = callback;
        this.targetList = Object.keys(this.rayList).map(key => this.rayList[key].target);
    }
    off(target) {
        delete this.rayList[target.id];
        this.targetList = Object.keys(this.rayList).map(key => this.rayList[key].target);
    }
    update(camera) {
        if (this.targetList.length <= 0) return;
        //更新射线位置
        this.raycaster.setFromCamera(this._center,camera);
        const intersects = this.raycaster.intersectObjects(this.targetList);
        if (intersects.length > 0) { // 当前帧射线击中物体
            const currentTarget = intersects[0].object;
            if (this._lastTarget) { // 上一帧射线击中物体
                if (this._lastTarget.id !== currentTarget.id) { // 上一帧射线击中物体与当前帧不同
                    this.rayList[this._lastTarget.id].gazeLeave(); 
                    this.rayList[currentTarget.id].gazeEnter();
                }
            } else { // 上一帧射线未击中物体
                this.rayList[currentTarget.id].gazeEnter(); // 触发当前帧物体的gazeEnter事件
            }
            this.rayList[currentTarget.id].gazeTrigger(); // 当前帧射线击中物体,触发物体的gazeTrigger事件
            this._lastTarget = currentTarget;
        } else { // 当前帧我击中物体
            if ( this._lastTarget ) this.rayList[this._lastTarget.id].gazeLeave(); // 触发上一帧物体gazeLeave
            this._lastTarget = null;
        }
    }
}

下面一起来看Gazer实现的三步曲,这里用“击中”表示射线与物体相交。

第一步,使用构造函数constructor初始化:
  1. 初始化射线发射器raycaster实例;
  2. 创建rayList以记录注册gaze事件的物体对象;
  3. 创建lastTarget记录前一帧被射线击中的物体,初始为null。
第二步,创建on方法提供事件绑定API

通过调用gazer.on(target,eventType,callback)方式,传入绑定事件的Obect3D对象target,绑定事件类型eventType以及事件回调callback三个参数。

  1. 判断这个target是否存在,不存在,则创建一个监听对象,存在则更新对象里的事件函数。这个对象包括传入的target本身,以及三个基本事件的回调函数(初始值为空方法):
this.rayList[target.id] = { 
   target, 
   gazeEnter, 
   gazeTrigger, 
   gazeLeave
}

将这个对象以键值对形式赋值给raylist[target.id]监听序列对象;

  1. raylist对象处理成[ target1, ..., targetN ]的形式赋值给this.targetList,作为raycaster.intersectObjects的入参。
第三步,创建update方法,在动画帧中监听三个基本事件是否触发
  1. 调用raycaster.setFromCamera更新射线起点与方向;
  2. 调用raycaster.intersectObjects检测监听序列this.targetList是否有物体与射线相交;
  3. 根据gazeEntergazeLeavegazeTrigger实现的情况,总结了以下这三个事件触发的逻辑图。
gaze基本事件逻辑图

逻辑图里的三个条件用代码表示如下:

当前帧射线是否击中物体:if (intersects.length > 0)
上一帧射线是否击中物体:if (this._lastTarget)
当前帧射线击中物体是否与上一帧不同:if (this._lastTarget.id !== currentTarget.id)

if (intersects.length > 0) { // 当前帧射线击中物体
    const currentTarget = intersects[0].object;
    if (this._lastTarget) { // 上一帧射线击中物体
        if (this._lastTarget.id !== currentTarget.id) { 
            // 上一帧射线击中物体与当前帧不同,触发上一帧物体的gazeLeave事件,触发当前帧物体的gazeEnter事件
            this.rayList[this._lastTarget.id].gazeLeave(); 
            this.rayList[currentTarget.id].gazeEnter();
        }
    } else { // 上一帧射线未击中物体
        this.rayList[currentTarget.id].gazeEnter(); // 上一帧射线没有击中物体,触发当前帧物体的gazeEnter事件
    }
    this.rayList[currentTarget.id].gazeTrigger(); // 当前帧射线击中物体,触发物体的gazeTrigger事件
    this._lastTarget = currentTarget;
} else { // 当前帧我击中物体
    if ( this._lastTarget ) this.rayList[this._lastTarget.id].gazeLeave(); // 上一帧射线击中物体,触发上一帧物体gazeLeave
    this._lastTarget = null;
}

最后,我们需要更新this._lastTarget值,供下一帧进行逻辑判断,如果当前帧有物体击中,则this._lastTarget = currentTarget,否则执行this._lastTarget = null

事件绑定示例

接下来,我们调用前面定义的Gazer类开发gaze交互,实现一个简单例子:随机创建100个cube立方体,当用户注视立方体时,立方体半透明。
首先创建准心,设置为一个圆点作为展现给用户的光标,当然你可以创建其它准心形状,比如十字形或环形等。

// 创建准心
createCrosshair () {
    const geometry = new THREE.CircleGeometry( 0.002, 16 );
    const material = new THREE.MeshBasicMaterial({
        color: 0xffffff,
        opacity: 0.5,
        transparent: true
    });
    const crosshair = new THREE.Mesh(geometry,material);
    crosshair.position.z = -0.5;
    return crosshair;
}

接下来,在start()方法创建物体并绑定事件,在update监听事件。

// 场景物体初始化
start() {
    const { scene, camera } = this;
    ... 创建灯光、地板等
    // 添加准心到相机
    camera.add(this.createCrosshair());
    this.gazer = new Gazer();
    // 创建立方体
    for (let i = 0; i < 100; i++) {
        const cube = this.createCube(2,2,2 );
        cube.position.set( 100*Math.random() - 50, 50*Math.random() -10, 100*Math.random() - 50 );
        scene.add(cube);
        // 绑定注视事件
        this.gazer.on(cube,'gazeEnter',() => {
            cube.material.opacity = 0.5;
        });
        this.gazer.on(cube,'gazeLeave',() => {
            cube.material.opacity = 1;
        });
    }
}
// 动画更新
update() {
    const { scene, camera, renderer, gazer } = this;
    gazer.update(camera);
    renderer.render(scene, camera);
}

在示例中,我们遵循上一期WebVRApp的代码结构,在start方法里增加了一个准心,为100个cube立方体绑定gazeEnter事件和gazeLeave事件,触发gazeEnter时,立方体半透明,触发gazeLeave时,立方体恢复不透明。

gaze注视交互

演示地址:yonechen.github.io/WebVR-helloworld/cardboard.html
源码地址:github.com/YoneChen/WebVR-helloworld/blob/master/cardboard.html


注视事件除了以上三种基本事件外,还衍生了像注视延迟事件和注视点击事件,这些gaze事件都可以在gazeTrigger里进行拓展。

注视点击事件

cardboard二代在盒子上提供了一个按钮,当用户通过注视物体并点击按钮,由按钮点击屏幕触发。
实现思路:在window绑定click事件,触发click时改变标志位,在gazeTrigger方法内根据标志位来判断是否执行回调,关键代码如下:

//按钮事件监听
window.addEventListener('click', e => this.state._clicked = true);
this.gazer.on(cube,'gazeTrigger',() => {
    // 当用户点击时触发
    if (this.state._clicked) {
        this.state._clicked = false; // 重置点击标志位
        cube.scale.set(1.5,1.5,1.5); // TODO
    }
});
注视延迟事件

当准心在物体上超过一定时间时触发,一般会在准心处设置一个进度条动画。

注视延迟事件

实现思路:在gazeEnter时记录开始时间点,在gazeTrigger计算出时间差是否超过预设延迟时间,如果是则执行回调,关键代码如下:

//准心进入物体,开启事件触发计时
this.gazer.on(cube,'gazeEnter',() => {
    this.state._wait = true; // 计时已开始
    this.animate.loader.start(); // 开启准心进度条动画
    this.state.gazeEnterTime = Date.now(); // 记录计时开始时间点
});
this.gazer.on(cube,'gazeTrigger',() => {
    // 当计时已开始,且延迟时长超过1.5秒触发
    if (this.state._wait && Date.now() - this.state.gazeEnterTime > 1500) {
        this.animate.loader.stop(); // 停止准心进度条动画
        this.state._wait = false; // 计时结束
        cube.material.opacity = 0.5; // TODO
    }
});
this.gazer.on(cube,'gazeLeave',() => {
    this.animate.loader.stop(); // 停止准心进度条动画
    this.state._wait = false; // 计时结束
    ...
});

这里准心计时进度条loader动画使用了Tween.js,这里就不展开了,更多可在源码地址查看。

演示地址:yonechen.github.io/WebVR-helloworld/cardboard2.html
源码地址:github.com/YoneChen/WebVR-helloworld/blob/master/cardboard2.html


小结

本文介绍了VR注视事件gaze原理以及开发过程,核心是通过raycaster来实现3d场景的物体拾取。为了方便调用,我将上述的gaze事件监听机制封装了一个插件 gaze-event ,欢迎查看。


WebVR开发传送门:

WebVR开发教程——交互事件(二)使用Gamepad
WebVR开发教程——深度剖析 关于WebVR的开发调试方案以及原理机制
WebVR开发教程——标准入门 使用Three.js开发WebVR场景的入门教程

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 159,716评论 4 364
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,558评论 1 294
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 109,431评论 0 244
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,127评论 0 209
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,511评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,692评论 1 222
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,915评论 2 313
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,664评论 0 202
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,412评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,616评论 2 245
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,105评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,424评论 2 254
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,098评论 3 238
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,096评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,869评论 0 197
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,748评论 2 276
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,641评论 2 271

推荐阅读更多精彩内容