Cardboard與gaze注視
Cardboard可以說是手機(jī)VR頭顯的元老了掰烟,狹義上指的是Google推出的一個帶有雙凸透鏡的盒子一姿,廣義上則表示智能手機(jī)+盒子的VR體驗平臺。
它的交互方式較為簡單登渣,利用了手機(jī)的陀螺儀,采用gaze注視行為來觸發(fā)場景里的事件,比如用戶在虛擬商店中注視一款商品時始绍,彈出這個商品的價格信息。
注視事件是WebVR最基本的交互方式话侄,用戶通過頭部運動改變視線朝向亏推,當(dāng)用戶視線正對著物體時,觸發(fā)物體綁定的事件年堆,具體分為三個基本事件吞杭,分別是gazeEnter
,gazeTrigger
,gazeLeave
。
我們可以設(shè)置一個位于相機(jī)中心的準(zhǔn)心來描述這三個基本事件(準(zhǔn)確的說变丧,在VR模式下是兩個芽狗,分別位于左右相機(jī)的中心)
- gazeEnter:當(dāng)準(zhǔn)心進(jìn)入物體時,即用戶注視了物體痒蓬,觸發(fā)一次
- gazeLeave:當(dāng)準(zhǔn)心離開物體時童擎,即用戶停止注視該物體時,觸發(fā)一次
- gazeTrigger:當(dāng)準(zhǔn)心處于物體時觸發(fā)攻晒,不同于gazeEnter顾复,gazeTrigger會在每一幀刷觸發(fā),直到準(zhǔn)心離開物體
注視事件原理
注視事件觸發(fā)條件其實就是物體被用戶視線“擊中”鲁捏。在每幀動畫渲染中芯砸,從準(zhǔn)心處沿z軸負(fù)方向發(fā)出射線,如果射線與物體相交给梅,即物體被射線擊中假丧,說明前方的物體被用戶注視,這里使用Three提供的raycaster對象,對場景里的3d物體進(jìn)行射線拾取动羽。
下面是使用THREE.Raycaster
拾取物體的簡單例子:
// 創(chuàng)建射線發(fā)射器實例raycaster
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(origin,camera); // 設(shè)置射線源點
raycaster.intersectObjects(targetList); // 檢測targetList的object物體是否與射線相交
if (intersects.length > 0) {
// 獲取從源點觸發(fā)包帚,與射線相交的首個物體
const target = intersects[0].object;
// TODO
}
主要分為三步:
-
new THREE.Raycaster()
創(chuàng)建一個射線發(fā)射器; - 調(diào)用
.setFromCamera(origin,camera)
設(shè)置射線發(fā)射源位置曹质,第一個參數(shù)origin傳入NDC標(biāo)準(zhǔn)化設(shè)備坐標(biāo)婴噩,即歸一化的屏幕坐標(biāo)擎场,第二個參數(shù)傳入相機(jī),此時射線將在屏幕的origin處几莽,沿垂直于相機(jī)的近切面的方向進(jìn)行投射迅办; - 調(diào)用
.intersectObjects(targetList)
檢測targetList的物體是否相交
Raycaster
借鑒了光線投射法進(jìn)行物體拾取,更多用法可參考three.js官方文檔
gazeEnter, gazeLeave, gazeTrigger實現(xiàn)
根據(jù)上文對gaze基本事件的描述章蚣,現(xiàn)在開始創(chuàng)建注視監(jiān)聽器Gazer
類站欺,提供事件綁定on
、解綁off
纤垂、更新update
的公用方法矾策,物體可注冊gazeEnter
,gazeLeave
,gazeTrigger
事件回調(diào),以下是完整代碼峭沦。
// 注視事件監(jiān)聽器
class Gazer {
constructor() {
// 初始化射線發(fā)射源
this.raycaster = new THREE.Raycaster();
this._center = new THREE.Vector2();
this.rayList = {},this.targetList = [];
this._lastTarget = null;
}
/** 物體綁定gaze事件的公用方法
* @param {THREE.Object3D} target 監(jiān)聽的3d網(wǎng)格
* @param {String} eventType 事件類型
* @param {Function} callback 事件回調(diào)
**/
on(target, eventType, callback) {
const noop = () => {};
// target首次綁定事件贾虽,則創(chuàng)建監(jiān)聽對象,加入raylist監(jiān)聽列表吼鱼,并將三個基本事件的回調(diào)初始為空方法
if (!this.rayList[target.id]) this.rayList[target.id] = { target, gazeEnter: noop, gazeTrigger: noop, gazeLeave: noop };
// 根據(jù)傳入的 eventType與callback更新事件回調(diào)
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) { // 當(dāng)前幀射線擊中物體
const currentTarget = intersects[0].object;
if (this._lastTarget) { // 上一幀射線擊中物體
if (this._lastTarget.id !== currentTarget.id) { // 上一幀射線擊中物體與當(dāng)前幀不同
this.rayList[this._lastTarget.id].gazeLeave();
this.rayList[currentTarget.id].gazeEnter();
}
} else { // 上一幀射線未擊中物體
this.rayList[currentTarget.id].gazeEnter(); // 觸發(fā)當(dāng)前幀物體的gazeEnter事件
}
this.rayList[currentTarget.id].gazeTrigger(); // 當(dāng)前幀射線擊中物體蓬豁,觸發(fā)物體的gazeTrigger事件
this._lastTarget = currentTarget;
} else { // 當(dāng)前幀我擊中物體
if ( this._lastTarget ) this.rayList[this._lastTarget.id].gazeLeave(); // 觸發(fā)上一幀物體gazeLeave
this._lastTarget = null;
}
}
}
下面一起來看Gazer
實現(xiàn)的三步曲,這里用“擊中”表示射線與物體相交菇肃。
第一步地粪,使用構(gòu)造函數(shù)constructor
初始化:
- 初始化射線發(fā)射器
raycaster
實例; - 創(chuàng)建
rayList
以記錄注冊gaze事件的物體對象琐谤; - 創(chuàng)建
lastTarget
記錄前一幀被射線擊中的物體蟆技,初始為null。
第二步斗忌,創(chuàng)建on
方法提供事件綁定API
通過調(diào)用gazer.on(target,eventType,callback)
方式质礼,傳入綁定事件的Obect3D對象target
,綁定事件類型eventType
以及事件回調(diào)callback
三個參數(shù)飞蹂。
- 判斷這個target是否存在几苍,不存在,則創(chuàng)建一個監(jiān)聽對象陈哑,存在則更新對象里的事件函數(shù)。這個對象包括傳入的target本身伸眶,以及三個基本事件的回調(diào)函數(shù)(初始值為空方法):
this.rayList[target.id] = {
target,
gazeEnter,
gazeTrigger,
gazeLeave
}
將這個對象以鍵值對形式賦值給raylist[target.id]
監(jiān)聽序列對象惊窖;
- 將
raylist
對象處理成[ target1, ..., targetN ]
的形式賦值給this.targetList
,作為raycaster.intersectObjects
的入?yún)ⅰ?/li>
第三步厘贼,創(chuàng)建update
方法界酒,在動畫幀中監(jiān)聽三個基本事件是否觸發(fā)
- 調(diào)用
raycaster.setFromCamera
更新射線起點與方向; - 調(diào)用
raycaster.intersectObjects
檢測監(jiān)聽序列this.targetList
是否有物體與射線相交嘴秸; - 根據(jù)
gazeEnter
和gazeLeave
和gazeTrigger
實現(xiàn)的情況毁欣,總結(jié)了以下這三個事件觸發(fā)的邏輯圖庇谆。
邏輯圖里的三個條件用代碼表示如下:
當(dāng)前幀射線是否擊中物體:
if (intersects.length > 0)
上一幀射線是否擊中物體:if (this._lastTarget)
當(dāng)前幀射線擊中物體是否與上一幀不同:if (this._lastTarget.id !== currentTarget.id)
if (intersects.length > 0) { // 當(dāng)前幀射線擊中物體
const currentTarget = intersects[0].object;
if (this._lastTarget) { // 上一幀射線擊中物體
if (this._lastTarget.id !== currentTarget.id) {
// 上一幀射線擊中物體與當(dāng)前幀不同,觸發(fā)上一幀物體的gazeLeave事件凭疮,觸發(fā)當(dāng)前幀物體的gazeEnter事件
this.rayList[this._lastTarget.id].gazeLeave();
this.rayList[currentTarget.id].gazeEnter();
}
} else { // 上一幀射線未擊中物體
this.rayList[currentTarget.id].gazeEnter(); // 上一幀射線沒有擊中物體饭耳,觸發(fā)當(dāng)前幀物體的gazeEnter事件
}
this.rayList[currentTarget.id].gazeTrigger(); // 當(dāng)前幀射線擊中物體,觸發(fā)物體的gazeTrigger事件
this._lastTarget = currentTarget;
} else { // 當(dāng)前幀我擊中物體
if ( this._lastTarget ) this.rayList[this._lastTarget.id].gazeLeave(); // 上一幀射線擊中物體执解,觸發(fā)上一幀物體gazeLeave
this._lastTarget = null;
}
最后寞肖,我們需要更新this._lastTarget
值,供下一幀進(jìn)行邏輯判斷衰腌,如果當(dāng)前幀有物體擊中新蟆,則this._lastTarget = currentTarget
,否則執(zhí)行this._lastTarget = null
右蕊。
事件綁定示例
接下來琼稻,我們調(diào)用前面定義的Gazer
類開發(fā)gaze交互,實現(xiàn)一個簡單例子:隨機(jī)創(chuàng)建100個cube立方體饶囚,當(dāng)用戶注視立方體時欣簇,立方體半透明。
首先創(chuàng)建準(zhǔn)心坯约,設(shè)置為一個圓點作為展現(xiàn)給用戶的光標(biāo)熊咽,當(dāng)然你可以創(chuàng)建其它準(zhǔn)心形狀,比如十字形或環(huán)形等闹丐。
// 創(chuàng)建準(zhǔn)心
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()
方法創(chuàng)建物體并綁定事件,在update
監(jiān)聽事件卿拴。
// 場景物體初始化
start() {
const { scene, camera } = this;
... 創(chuàng)建燈光衫仑、地板等
// 添加準(zhǔn)心到相機(jī)
camera.add(this.createCrosshair());
this.gazer = new Gazer();
// 創(chuàng)建立方體
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的代碼結(jié)構(gòu)堕花,在start
方法里增加了一個準(zhǔn)心文狱,為100個cube立方體綁定gazeEnter
事件和gazeLeave
事件,觸發(fā)gazeEnter
時缘挽,立方體半透明瞄崇,觸發(fā)gazeLeave
時,立方體恢復(fù)不透明壕曼。
演示地址:yonechen.github.io/WebVR-helloworld/cardboard.html
源碼地址:github.com/YoneChen/WebVR-helloworld/blob/master/cardboard.html
注視事件除了以上三種基本事件外苏研,還衍生了像注視延遲事件和注視點擊事件,這些gaze事件都可以在gazeTrigger
里進(jìn)行拓展腮郊。
注視點擊事件
cardboard二代在盒子上提供了一個按鈕摹蘑,當(dāng)用戶通過注視物體并點擊按鈕,由按鈕點擊屏幕觸發(fā)轧飞。
實現(xiàn)思路:在window
綁定click事件衅鹿,觸發(fā)click時改變標(biāo)志位撒踪,在gazeTrigger
方法內(nèi)根據(jù)標(biāo)志位來判斷是否執(zhí)行回調(diào),關(guān)鍵代碼如下:
//按鈕事件監(jiān)聽
window.addEventListener('click', e => this.state._clicked = true);
this.gazer.on(cube,'gazeTrigger',() => {
// 當(dāng)用戶點擊時觸發(fā)
if (this.state._clicked) {
this.state._clicked = false; // 重置點擊標(biāo)志位
cube.scale.set(1.5,1.5,1.5); // TODO
}
});
注視延遲事件
當(dāng)準(zhǔn)心在物體上超過一定時間時觸發(fā)大渤,一般會在準(zhǔn)心處設(shè)置一個進(jìn)度條動畫制妄。
實現(xiàn)思路:在
gazeEnter
時記錄開始時間點,在gazeTrigger
計算出時間差是否超過預(yù)設(shè)延遲時間兼犯,如果是則執(zhí)行回調(diào)忍捡,關(guān)鍵代碼如下:
//準(zhǔn)心進(jìn)入物體,開啟事件觸發(fā)計時
this.gazer.on(cube,'gazeEnter',() => {
this.state._wait = true; // 計時已開始
this.animate.loader.start(); // 開啟準(zhǔn)心進(jìn)度條動畫
this.state.gazeEnterTime = Date.now(); // 記錄計時開始時間點
});
this.gazer.on(cube,'gazeTrigger',() => {
// 當(dāng)計時已開始切黔,且延遲時長超過1.5秒觸發(fā)
if (this.state._wait && Date.now() - this.state.gazeEnterTime > 1500) {
this.animate.loader.stop(); // 停止準(zhǔn)心進(jìn)度條動畫
this.state._wait = false; // 計時結(jié)束
cube.material.opacity = 0.5; // TODO
}
});
this.gazer.on(cube,'gazeLeave',() => {
this.animate.loader.stop(); // 停止準(zhǔn)心進(jìn)度條動畫
this.state._wait = false; // 計時結(jié)束
...
});
這里準(zhǔn)心計時進(jìn)度條loader動畫使用了Tween.js
砸脊,這里就不展開了,更多可在源碼地址查看纬霞。
演示地址:yonechen.github.io/WebVR-helloworld/cardboard2.html
源碼地址:github.com/YoneChen/WebVR-helloworld/blob/master/cardboard2.html
小結(jié)
本文介紹了VR注視事件gaze原理以及開發(fā)過程凌埂,核心是通過raycaster
來實現(xiàn)3d場景的物體拾取。為了方便調(diào)用诗芜,我將上述的gaze事件監(jiān)聽機(jī)制封裝了一個插件 gaze-event 瞳抓,歡迎查看。
WebVR開發(fā)傳送門:
WebVR開發(fā)教程——交互事件(二)使用Gamepad
WebVR開發(fā)教程——深度剖析 關(guān)于WebVR的開發(fā)調(diào)試方案以及原理機(jī)制
WebVR開發(fā)教程——標(biāo)準(zhǔn)入門 使用Three.js開發(fā)WebVR場景的入門教程