最近公司的vr項目換成了ue4引擎來搭建場景, 老大布置任務: 在手機上實現一個虛擬手柄來與場景通信, 于是便有了這個預研的測試demo, 這個demo實現了1.可控幀率,滿足游戲需求, 2.搖桿控制,按住會持續發送消息,3. 雙指縮放事件與 UE4 同步, 編寫使用的前端庫是nipplejs
演示地址: gusuziyi.github.io/rockerforue4/,
倉庫地址:https://github.com/gusuziyi/rockerForUE4.git
簡單的手柄設置可以參考官網 https://yoannmoi.net/nipplejs/#demo
用官網的demo跑起來之后, 還有一些實際應用的問題要解決:
可控幀率
由于搖桿在使用時產生的數據過多, 不能不加處理全部發給服務器, 會造成渲染引擎卡死, 這時候要使用定時器做基本的節流優化.
關于節流的知識點,請先戳這里: 淺談js防抖和節流
- 這里我在通信入口統一設置一個定時器, 并根據幀率frame算出發送間隔Math.round(1000 / frame)
//時間校準, 通信函數入口
timerControl(data) {
//發送隊列不為空,拒絕發送并等待發送完成
if (this.timer) {
return;
}
//第一次發送,直接發送
if (!this.oldTime) {
this.oldTime = +new Date();
return this.beforeSend(0, data);
}
//發送間隔未達標,執行節流
const now = +new Date();
if (now - this.oldTime < this.intervalTime) {
return this.beforeSend(this.intervalTime + this.oldTime - now, data);
}
//其他情況,直接發送
return this.beforeSend(0, data);
},
- 而beforeSend函數則是一個根據節流函數改變的幀率控制函數,主要是利在發送之后生成一個this.timer, 然后在timerControl函數中判斷 此變量來達到節流的目的
// 幀率控制
beforeSend(time, data) {
if (time === 0) {
this.msg = noticeServer(this.operaterCMD, data);
this.timer = setTimeout(() => {
clearTimeout(this.timer);
this.timer = null;
}, this.intervalTime);
} else {
this.timer = setTimeout(() => {
this.msg = noticeServer(this.operaterCMD, { X, Y });
clearTimeout(this.timer);
this.timer = null;
}, time);
}
}
- 服務端的插值算法
前端節流之后, 在服務端也要做相應的插值算法, 通過緩存一幀的方式, 來進一步節流,這里給出一個示例demo
// 插值算法,保證不卡頓
insertValueA(y, x) {
// 第一幀,緩存下來,不繪制
if (!this.topA || !this.leftA) {
this.topA = this.oldTopA + "px";
this.leftA = this.oldLeftA + "px";
return;
}
// 下一幀, 分9次繪制出下一幀與上一幀的變化量,這里的n=9要根據前后端的幀率協定來控制
let n = 9;
let stepY = (y - this.oldTopA) / n;
let stepX = (x - this.oldLeftA) / n;
let insertValueATimer = setInterval(() => {
this.topA = +this.topA.slice(0, this.topA.indexOf("px")) + stepY + "px";
this.leftA = +this.leftA.slice(0, this.leftA.indexOf("px")) + stepX + "px";
n--;
if (n === 0) {
clearInterval(insertValueATimer);
insertValueATimer = null;
}
}, 20);
// 緩存下一幀
this.oldTopA = y;
this.oldLeftA = x;
},
縮放手勢監聽
- 縮放手勢在安卓為touch事件,在IOS上為gesture事件,所以在注冊監聽時,要同時注冊兩個事件
- 由于縮放是至少2個手指才能完成的動作, 所以在start中要監聽到兩個以上的點才觸發,注意start中不要寫e.preventDefault(),這會導致單指點擊按鈕失效
- 由于在手指縮放時要屏蔽其他動作,所以要在縮放時為事件添加一個開關istouch, 在start時,開啟,在end時關閉, 若在縮放時有其他手指事件被觸發, 只需判斷istouch的狀態即可
const that = this;
['touchstart', 'gesturestart'].forEach(i => {
document.addEventListener(
i,
function(e) {
if (e.touches.length >= 2) {
that.istouch = true;
start = e.touches; // 得到第一組兩個點
}
},
{ passive: false }
);
});
- 獲取是放大還是縮小指令, 要根據每次手指移動后兩個觸點的長度來判斷
完整的手指縮放監聽函數:
//雙指縮放
setGesture() {
const that = this;
function getDistance(p1, p2) {
const x = p2.pageX - p1.pageX;
const y = p2.pageY - p1.pageY;
return Math.sqrt(x * x + y * y);
}
let start = [];
['touchstart', 'gesturestart'].forEach(i => {
document.addEventListener(
i,
function(e) {
if (e.touches.length >= 2) {
that.istouch = true;
start = e.touches; // 得到第一組兩個點
}
},
{ passive: false }
);
});
['touchmove', 'gesturemove'].forEach(i => {
document.addEventListener(
i,
function(e) {
e.preventDefault();
if (e.touches.length >= 2 && that.istouch) {
const now = e.touches; // 得到第二組兩個點
const scale =
getDistance(now[0], now[1]) / getDistance(start[0], start[1]);
that.operaterCMD = 'scale';
that.timerControl({ scale: scale.toFixed(2) });
}
},
{ passive: false }
);
});
['touchend', 'gestureend'].forEach(i => {
document.addEventListener(
i,
function() {
if (that.istouch) {
that.istouch = false;
}
},
{ passive: false }
);
});
}
搖桿按住持續發送指令
- 在nipplejs 中翻遍文檔和issue, 發現只有搖桿移動事件,并沒有
按住持續發送指令
的功能,所以只能自己實現 - 在搖桿start和end事件中為搖桿添加一個active狀態,類似以下偽代碼:
this[搖桿.name]
.on('start', () => {
this[搖桿.name].active = true;
})
.on('move', this.onMove)
.on('end', () => {
this[搖桿.name].active = false;
});
- 然后為搖桿添加一個持續按住的定時器Interval, 然后在搖桿的move事件中不斷調用并重新賦值,一旦move事件結束,則該定時器會持續觸發,此時將定時器與搖桿的active狀態綁定,即可實現松開搖桿時取消事件發送.也就間接實現了搖桿按住持續發送指令功能
//搖桿移動
onMove(e, data, m) {
this.rockerKeepPress();
const X = +data.vector.x.toFixed(2);
const Y = +data.vector.y.toFixed(2);
this.cachePosition = [X, Y];
this.timerControl({ X, Y });
},
//搖桿持續按住
rockerKeepPress() {
if (this.onPressTimer) {
clearInterval(this.onPressTimer);
}
this.onPressTimer = setInterval(() => {
if (this[搖桿.name].active) {
this.timerControl({
X: this.cachePosition[0],
Y: this.cachePosition[1],
});
} else {
clearInterval(this.onPressTimer);
this.onPressTimer = null;
}
}, this.intervalTime);
},