前言
随着智能硬體的普及,手機,平闆,PC甚至路邊的電子廣告牌,現代浏覽器已經無處不在。在浏覽器裡編織出我們自己的一片天地已經輕車熟路,但是這還不夠,H5賦予了浏覽器太多的新特性,等待我們去使用。這篇文章介紹利用手機浏覽器的羅盤API,在PC的浏覽器實時地繪制一個3D盒模型。
這種炫酷的玩法叫做“多屏互動”,就像是把手機當做遊戲搖桿,PC顯示器當做電視機,不過這些都是在浏覽器裡實作的。
先上效果圖

(測試機是刷了小米系統的裂了螢幕的HTC霹靂2+Chrome浏覽器)
源碼請戳這裡:https://coding.net/u/OverTree/p/webSocketDemo/git
本地測試過程:
1. 在PC上,使用指令 node index.js,自動打開項目首頁。(請關閉ADsafe,如有虛拟機,請停用虛拟網卡)
2. 建立一個“房間”并自動進入“房間”。
3. 用手機掃描“房間”内任意位置的二維碼。
4. 確定手機和PC可以互相PING通
ADsafe是個很好用的去廣告軟體,但是會阻止本機IP通路,可能造成項目首頁打不開,是以請先暫時關閉
本程式會自動擷取本機IP,如果有虛拟網卡,IP位址可能擷取不正确
用戶端(浏覽器)
1. 手機浏覽器端
一個物體在空間内的旋轉體位,都可以用一個方向向量(x,y,z)和旋轉角度(angle)來表示。也就是CSS3
transform
的
rotate3d(x,y,z,angle)
這個函數的4個參數。
想要在浏覽器裡友善的繪制一個立體模型的的旋轉,重點就是利用手機浏覽器的H5新特性去擷取手機旋轉狀态的資料,然後轉化成這4個參數。
1.1 重力感應API
devicemotion
顧名思義裝置運動。其實不僅僅有重力感應的資料,還有移動加速度,擺動角度。
不過這個接口傾向于運動時瞬間的資料展示,靜止時,除了重力加速度,其他資料(移動加速度,擺動角度)基本為0。
window.addEventListener('devicemotion', deviceMotionHandler, true);
function deviceMotionHandler(evt){
if(evt.accelerationIncludingGravity){
document.body.innerHTML =
"x軸加速度: " + evt.accelerationIncludingGravity.x + "<br>"
+ "y軸加速度: " + evt.accelerationIncludingGravity.y + "<br>"
+ "z軸加速度: " + evt.accelerationIncludingGravity.z + "<br>"
}
if(evt.rotationRate ){
document.body.innerHTML +=
"x軸扭轉: " + evt.rotationRate.beta + "<br>"
+ "y軸扭轉: " + evt.rotationRate.gamma + "<br>"
+ "z軸扭轉: " + evt.rotationRate.alpha + "<br>"
}
}
(魅族老機型,安卓4.4.4的自帶浏覽器對此API支援不完全,請另外安裝QQ浏覽器)
在手機浏覽器裡運作以上代碼,并稍微晃動,會看到列印資料狂跳。拿到了資料,接下來開始觀察規律。
手機螢幕朝上,水準靜止放置,Z軸重力加速度為9.8,Y,X為0。
手機螢幕朝下,水準靜止放置,Z軸重力加速度為-9.8,Y,X為0。
手機話筒朝下,豎直靜止放置,Y重力加速度為9.8, X,Z為0。
手機話筒朝上,豎直靜止放置,Y重力加速度為-9.8, X,Z為0。
手機右側朝上,豎直靜止放置,X重力加速度為9.8, Y,Z為0。
手機左側朝下,豎直靜止放置,X重力加速度為-9.8, Y,Z為0。
那麼手機的空間坐标如下圖:
箭頭指向都是坐标正方向。
當手機開始傾斜,X,Y,Z軸的加速度分量都有值,且絕對值都小于9.8。根據分量的數值,是可以算出手機在三維空間的傾斜狀态,隻不過這個計算過程複雜,而且在手機運動時,重力加速度的值并不準确表達目前傾斜。一般不用這個資料去計算手機在三維空間的傾斜。
當手機水準放置,撥動手機,使其慢慢旋轉,重力加速度的資料并沒有變化。是以,重力感應的這個API,隻能擷取裝置目前的傾斜狀态,而無法擷取裝置的旋轉方向。而一些簡單的功能,比如搖一搖,晃一晃,就可以用這個接口去實作。
利用重力感應的API,可以輕松利用高中數學的反三角函數,實作XY二維平面的旋轉,效果如下:
代碼如下:
function deviceMotionHandler(evt){
var angle =
Math.atan2(
- evt.accelerationIncludingGravity.x ,
evt.accelerationIncludingGravity.y
).toFixed() / Math.PI * ;
}
這個 angle 就可以直接應用在DOM的CSS屬性
transform:rotate(angle deg)
上。
1.2 羅盤API
window.addEventListener('deviceorientation', deviceOrientationHandler, true);
function deviceMotionHandler(evt){
document.body.innerHTML =
"z軸旋轉(羅盤方向) alpha: " + event.alpha + "<br>"
+ "y軸旋轉 gamma: " + event.gamma + "<br>"
+ "x軸旋轉 beta: " + event.beta
}
重點來了,
deviceorientation
能夠很好的表現物體在空間中的狀态,旋轉方向,傾斜角度,無論是靜止還是運動或者加速運動。
這裡要和
devicemotion
的
evt.rotationRate
區分一下,雖然都有alpha,gamma,beta 但是
devicemotion
描述的是旋轉變化了的角度值,物體角度變化才會有資料,靜止了之後就變為0,而
deviceorientation
的是描述是靜止時的角度值。
這三個數值的機關都是deg,如何轉化為CSS3
transform:rotate3d(x,y,z,angle)
的4個參數,對于沒有任何3D知識的前端狗來說是個挺麻煩的問題。
現在要引入一個概念:四元數
四元數是個高階複數 q = [w,x,y,z]。
四元數的基本數學方程為 :
q = cos (a/2) + i(x * sin(a/2)) + j(y * sin(a/2)) + k(z * sin(a/2))
其中a表示旋轉角度,(x,y,z)表示旋轉軸。
四元數表示一個完整的旋轉。
四元數可以由各軸旋轉角(alpha,beta,gamma)求得。
四元數可以轉換旋轉軸(x,y,z)和旋轉角度(angle)。
作為初試,本篇并不深入讨論四元數的具體定義,難點是擷取四元數[w,x,y,z]。好在官方提供了旋轉角(alpha,beta,gamma)轉換成四元數的方法 https://w3c.github.io/deviceorientation/spec-source-orientation.html
在這個頁面内搜尋 getQuaternion。
另外我根據數學公式反求,寫了一個四元數轉(x,y,z,angle) 的函數 getAcQuaternion。代碼如下:
var degtorad = Math.PI / ;
function getQuaternion( alpha, beta, gamma ) { //官方求四元數方法
var _x = beta ? beta * degtorad : ; // beta value
var _y = gamma ? gamma * degtorad : ; // gamma value
var _z = alpha ? alpha * degtorad : ; // alpha value
var cX = Math.cos( _x/ );
var cY = Math.cos( _y/ );
var cZ = Math.cos( _z/ );
var sX = Math.sin( _x/ );
var sY = Math.sin( _y/ );
var sZ = Math.sin( _z/ );
var w = cX * cY * cZ - sX * sY * sZ;
var x = sX * cY * cZ - cX * sY * sZ;
var y = cX * sY * cZ + sX * cY * sZ;
var z = cX * cY * sZ + sX * sY * cZ;
return [ w, x, y, z ];
}
function getAcQuaternion( _w, _x, _y, _z ) { //我的四元數轉旋轉軸和旋轉角度方法
var rotate = * Math.acos(_w)/degtorad ;
var x = _x / Math.sin(degtorad * rotate/) || ;
var y = _y / Math.sin(degtorad * rotate/) || ;
var z = _z / Math.sin(degtorad * rotate/) || ;
return {x:x,y:y,z:z,rotate:rotate};
}
function deviceMotionHandler(evt){ // deviceorientation 事件處理函數
var qu = getQuaternion(evt.alpha,evt.beta,evt.gamma);
var rotate3d = getAcQuaternion(qu[],qu[],qu[],qu[]);
// rotate3d的參數已經有了,随你處理咯。我是把他送給伺服器,交給PC,在PC上顯示旋轉
}
1.3 校準
這裡有個3D裡的概念,錄影機位置。我們的PC顯示器就是一個錄影機。隻能被動的從某一個角度展示拍攝的景象。正常情況下,手機所在平面應該和顯示器所在平面平行,且垂直于地平面的角度。就好比是,錄影機正對着手機正面拍攝。
如果校準的時候手機并沒有垂直于地平面,錄影機的位置就不一定是正前方了。這時候展示的畫面并不是水準同步的了。
如下圖所示,校準時,手機螢幕朝上。這時候錄影機位置就在天花闆上了,你看到的成像就是俯視圖。
同理,校準時,手機螢幕朝下,這時候錄影機的位置就是在地上,往上拍攝,你看到的成像就是仰視圖。
總結起來就是:校準時,手機螢幕朝着哪裡,錄影機就在那裡拍攝着螢幕,一動不動。
1.4 相容性
demo的相容性測試并不理想,在iOS平台上測試良好,且流暢。
在安卓平台上,除了chrome浏覽器之外的浏覽器,會出現各種問題,主要表現在羅盤資料不準确。
而chrome浏覽器并沒有掃一掃功能,因為在國外并不流行這個玩意。是以在安卓平台上就很蛋疼,還要多裝一個我查查,才能完整體驗。
(如果出現旋轉不準确的問題,可以嘗試校準羅盤,大概就是拿着手機畫8。百度一下方法有很多)
代碼如果有相容寫法,或者有其他相容問題請賜教,可以在coding上私信我(OverTree),不勝感激。
2. PC浏覽器端
PC浏覽器的作用就是能夠顯示房間資訊,建立房間。
顯示房間,建立時間,參與人數,點選進入。建立一個房間,成功後自動進入房間。
在房間内,接受伺服器轉發的手機端的消息,并作出相應動作,包括上線,校準,旋轉,下線。
上線時,安排就坐(隐藏二維碼,顯示模型)
校準時,重新設定模型的顯示角度
旋轉時,就旋轉咯
下線時,重新顯示二維碼(顯示二維碼,隐藏模型)
2.1初始化, 建立ws連接配接
重點是房間裡的事情。是以這裡就隻介紹進入房間發生的事吧。
首先房間參數要正确,至少有房間編号。
- 房間路由:
roomNumber是一串16位随機字元串。/room/[roomNumber]
- 座位路由:
/room/[roomNumber]/[seatNumber]
var uri = win.location.pathname.split('/'),roomNumber;
function initUrlData(){
if(uri.length>= && uri[] == "room"){
roomNumber = uri[];
document.title = "虛拟房間 "+ roomNumber + "号"
return ;
}else{
window.location.href = "/index";
return ;
}
}
function initWebSocket(){
var wsUri = "ws://"+ window.location.hostname +":<%= config.wsport %>"+"/ws/room"; //這裡用了一個ejs的占位符,已便在伺服器更改websocket端口時可以及時使用正确端口。
var websocket = new WebSocket(wsUri);
websocket.onopen = function(evt) {
websocket.send(JSON.stringify({room:roomNumber}));
}; //連結建立後,發送一個消息,表明在哪個房間
websocket.onclose = function(evt) {
};
websocket.onmessage = function(evt) {
parseMessage(evt.data) //解析資料
};
websocket.onerror = function(evt) {
};
//綁定了這些處理函數之後,websocket開始建立連結,而不是 New 的時候開始建立
}
$(".room-place .qrcode").each(function(index,item){
$(item).qrcode({
"size": ,
"color": "#3a3",
"text": window.location.origin + "/room/" + roomNumber + "/" + (index+)
});
//這裡用jQuery的插件,jquery-qrcode 按照座位路由初始化二維碼
})
2.2 純CSS3立體模型
做為一名普通的前端人員,想要畫一個3D的模型,按照最熟悉的方法就是用CSS3了。(如果是用Three.js的大神請跳過本節)
不過要很快畫出一個六面體出來,還是需要想一想的,畢竟這個技能很少用。
畫一個長方體
<section class="container">
<div id="box" >
<figure class="front"><span>前</span></figure>
<figure class="back"><span>後</span></figure>
<figure class="right"><span>右</span></figure>
<figure class="left"><span>左</span></figure>
<figure class="top"><span>頂</span></figure>
<figure class="bottom"><span>底</span></figure>
</div>
</section>
<style>
*{
margin: ; /*不加會歪*/
}
.container {
width: px;
height: px;
position: relative;
perspective: px; /*錄影機距離,設定小的的話,立方體顯示會變形*/
}
#box figure {
display: block;
position: absolute;
border: px solid black;
line-height: px;
font-size: px;
text-align: center;
font-weight: bold;
color: white;
box-sizing: border-box; /*因為有2px寬的border,如果不設定為此值,那麼每個面的寬高都要少設定4個像素,才能對齊*/
}
#box {
width: %;
height: %;
position: absolute;
transform-style: preserve-d;/*這個很重要,預設是平面變形flat*/
}
#box .front,
#box .back {
width: px;
height: px;
}
#box .right,
#box .left {
width: px;
height: px;
left:px; /*調整*/
}
#box .top,
#box .bottom {
width: px;
height: px;
top:px; /*調整*/
line-height:px;
}
/*給每個面上半透明的顔色*/
#box .front { background: hsla( , %, %, ); }
#box .back { background: hsla( , %, %, ); }
#box .right { background: hsla( , %, %, ); }
#box .left { background: hsla( , %, %, ); }
#box .top { background: hsla( , %, %, ); }
#box .bottom { background: hsla( , %, %, ); }
#box .front { /*這個距離乘以2為前後面的距離*/
transform: translateZ( px );
}
#box .back { /*front面沿着x軸旋轉180度,做後面*/
transform: rotateX( -deg ) translateZ( px );
}
#box .right { /*這個距離乘以2為左右面的距離*/
transform: rotateY( deg ) translateZ( px );
}
#box .left { /*front面沿着y軸旋轉90度,做側面*/
transform: rotateY( -deg ) translateZ( px );
}
#box .top { /*這個距離乘以2為長方體高*/
transform: rotateX( deg ) translateZ( px );
}
#box .bottom { /*front面沿着x軸旋轉90度,做底面*/
transform: rotateX( -deg ) translateZ( px );
}
</style>
對這樣的css有什麼要吐槽的麼?
這樣的stylesheet簡直是刀耕火種時期的
如果用sass寫法,那麼隻需要寫一次#box和多層嵌套就可以了。
效果如下:
如果我們使用webGL去繪制的話,導入一些現成的3D模型,無論物體還是人物,都可以360度無死角的玩弄于手掌了。
(如果有蒼老師的模型,想想還有點小激動呢,VR的感覺說來就來啊 - -)
接下來就是等待來自手機端的旋轉資訊,x,y,z,angle,使#box進行transform旋轉就是了。
$seat.find("#box").
css("transform","rotate3d("
+ (-parseFloat(content.x))+"," //取反
+ (+parseFloat(content.y))+","
+ (-parseFloat(content.z))+"," //取反
+ content.rotate +"deg)");
不取反的話,旋轉是錯誤的。我曾多次嘗試給不同的坐标取反,最終得出這個取反方法,是唯一顯示正常的組合。
無法了解這兩個取反,猜測是因為css的x,y,z的坐标和實體裝置x,y,z的坐标方向有差異吧。畢竟顯示器是平面的,他的x,y,z的定義不能和手機傳感器一緻。
2.3 校準
PC端的校準就簡單多了,在#box外套一層div.adjust。
當接受來自手機端的校準資訊 x,y,z,angle,設定外套的 div.adjust 的旋轉為 x,y,z,-angle 就好了。
$seat.find(".adjust").
css("transform","rotate3d("
+ (-parseFloat(content.x))+","
+ (+parseFloat(content.y))+","
+ (-parseFloat(content.z))+","
+ (-parseFloat(content.rotate)) +"deg)"); //取反
當然,這個adjust的樣式至少包含以下樣式
.adjust{
position: absolute;
transform-style:preserve-d;
}
2.4 相容性
PC端的相容性就好多了,隻要是現代H5浏覽器基本上沒有相容性問題。
服務端
1. 資料結構
這個服務隻做臨時資料的儲存和消息轉發。
臨時資料:比如,各端的webSocket連接配接句柄,房間資訊等,我把它們放在global全局對象下,就好比是共享記憶體,通路友善,速度快。
global.ShareMem = {
rooms:{
"12345678":{ //房間号做為key,友善查找
player:[{socket:connection,place:place}], //手機端數組:連接配接句柄,座位号
projector:[], //PC端數組
id:"12345678",
startTime:Date.now(),
maxplayer:, //最多座位數
type:"ddd" //房間類型
}
}
};
2. webServer
如果您是nodejs的大神,或者在用koajs、express等nodejs架構,請跳過本大節。因為我用原生的nodejs寫了一遍webServer,雖然重複造輪子不好,但是複習複習webServer的基本知識,還是不錯的,本節适合新手入門。
包含知識點:header解析,靜态檔案查找,gzip,檔案hash計算,狀态碼。
2.1 目錄結構
/API
/funMap.js /*http功能函數集合*/
/xxx.js
/socketAPI
/funMap.js /*webSocket功能函數集合*/
/xxx.js
/Util /*工具目錄,擷取本地IP,打開預設浏覽器*/
/webRoot
/common /*公共資源目錄*/
/js
/lib
/css
/m /*移動端html,js,css等*/
/p /*PC端html,js,css等*/
/index.js /*入口檔案*/
/config.js /*配置檔案,端口号,ws最大資料包大小等*/
/socketServer.js /*webSocket處理函數*/
/webServer.js
2.2 webServer
基本規則是這樣的,搭建靜态伺服器,靜态資源正常讀取傳回,html檔案用ejs渲染後傳回。
由于ejs的原因,html檔案并沒有被修改,但是渲染後的内容被修改,比如,更改了ws的端口,但是html檔案沒有修改。是以不能使用
Last-Modified
來判斷是檔案是否最新,而是要根據傳回内容有沒有被改變來判斷,是以要用
Etag
。
Etag需要根據内容算出hash值,一般用md5計算。
傳回内容之前,需要進行gzip壓縮,用來節省帶寬。90KB的jquery.min.js可以被gzip到30KB,壓縮才是王道。
因為手機端和PC端執行的是完全不同的代碼,是以要判斷從用戶端傳過來的
user-agent
是否包含
Mobile
字元串,以來區分用戶端是PC還是手機,以便傳回正确的資源。
通過簡單的約定,來區分靜态檔案和REST請求
if (libPath.extname(pathName) == "") {
//如果路徑沒有擴充名
if(params.length<=){
pathName += "/"; //通路根目錄
}else if(params[]=="api"){ //通路以api開頭
parseAPI(params,req,res); //功能函數
return ;
}else{
pathName = params[]+".html";
}
}
我在這裡做了一個簡單的架構,在API目錄或者socketAPI目錄下新增js檔案,一個js檔案對應一個處理函數,然後在funMap.js中聚合為一個Map,友善查找函數,也容易隔離和修改函數名。
var funMap = {
"room":require("./room"),
"changeName":require("./xxx"),
"changeName2":require("./xxxyyy")
};
module.exports = funMap;
用戶端通路時就可以通過 /api/[functionName] 來通路想要的服務了。
3. webSocketServer
nodejs本身并沒有提供webSockerServer的子產品,是以需要另外安裝一個。
在npm install的時候會安裝一個ws子產品,require(“ws”) 就可以用了。用法與http子產品相似,都用
createServer({options},MainHandlerFunction)
建立服務,隻是ws多了幾個參數。
主要是
port
,注意不要和webserver端口重複。還有一個
maxPayload
就是單個ws資料包最大大小,機關是bytes,自己估計項目傳輸資料時候資料包大小。預設值是65535 即 64KB。一般webSocket用于小包傳輸,不用太大,我設定了1024 , 1KB。
主處理函數
MainHandlerFunction
,在有用戶端連接配接進來時會傳入一個參數
connection
,這個對象内容非常豐富,不看手冊,可以列印出來也慢慢研究。成功建立連接配接的方法就是要
connection
綁定
message
方法。
由于wsSocket通路是可以帶着url的,是以我們可以用url隔離不同的功能函數,而不是去解析message主體。
var connectHandler = function(connection){
// :4002/api/Function1
var URIarray = connection.upgradeReq.url.split("/")
if(funMap[URIarray[]]){
funMap[URIarray[]](connection);
}else{
connection.send("{err:Function Not Found!!}");
}
}
3.1 消息,廣播,保活
每當有ws連接配接進來,都有類似檔案描述符的id來區分每個不同的連接配接。
connection._ultron.id
用它可以區分自己與别人的連接配接,很有用。
//消息格式
function msgPack(){
return JSON.stringify({
"who":arguments[], // Mobile , PC
"place":arguments[], // 座位
"dowhat":arguments[], // "connect","ready","message","lost"
"content":arguments[]||"" // 内容
})
}
//以room為機關廣播,廣播房間内所有角色
function boradCast(room,msg,ignore){
room.projector.forEach(function(item,index){
if(ignore&&ignore._ultron.id===item.socket._ultron.id){
// console.log("ignore!!!")
// 忽略自己不發送給自己
}
else{
try{
item.socket.send(msg);
}catch(e){
console.log(e);
}
}
});
room.player.forEach(function(item,index){
if(ignore&&ignore._ultron.id===item.socket._ultron.id){
// console.log("ignore!!!")
// 忽略自己不發送給自己
}
else{
try{
item.socket.send(msg);
}catch(e){
console.log(e);
}
}
});
}
為了檢查用戶端是否掉線,在建立連接配接時手動加入保活機制,方法很簡單:
給用戶端發送空消息時lastkeeplife為1,隻要用戶端傳回任意消息,那麼更新lastkeeplife為0,如果5秒之内,沒有任何回複判定為掉線。如果用戶端掉線,那麼關閉連接配接,從連接配接池中移除。并廣播掉線消息給房間内其他角色。
var keeplifeHandler = setInterval(function(){
if(lastkeeplife == ){
connection.close();
connection.emit("close");
clearInterval(keeplifeHandler);
}
try{
lastkeeplife = ;
connection.send("{}");
}catch(e){
console.log("keep live error! "+ e +"\n\n");
connection.close();
connection.emit("close");
clearInterval(keeplifeHandler);
}
},)
connection.on('close',function(msg){
if(keeplifeHandler){ //關閉保活循環
clearInterval(keeplifeHandler);
}
console.log("close!",roomid,place);
var room = global.ShareMem.rooms[roomid];
if(!room)
return;
//從連接配接池移除連接配接句柄
if(platform === PC){
room.projector.forEach(function(item,index){
if(item.socket === connection){
room.projector.splice(index,);
return false;
}
})
}else{
room.player.forEach(function(item,index){
if(item.socket === connection){
room.player.splice(index,);
return false;
}
})
}
//發送掉線消息
boradCast( room, msgPack(platform,place,"lost") , connection );
});
iOS裝置如果鎖屏,會發送斷開資訊給伺服器,而安卓不會。想要斷開連結,必須等到預設120秒逾時後關閉。
ws初始化時并沒有提供初始化timeout的配置。通過修改
ws._server.timeout = 1000;//1秒逾時
并不會生效。問題來了,怎麼修改才能設定逾時時間呢?
目前隻能用上述比較捉急的方法來及時斷開掉線裝置。
最後
多屏互動已經不是新鮮的東西了,我做這個Demo還是受chrome實驗室一個叫做【光劍出鞘】的項目的啟發。因為體驗時需要手機端和PC同時翻-牆,導緻體驗差,然後自己才想做一個。做出來的時候感覺好酷炫,好神奇,好興奮。
後續還是有很多可以拓展和改進的,希望最終可以變為一個成熟的産品,而不是僅僅止步于Demo。
相關閱讀
- 無需Flash實作圖檔裁剪——HTML5中級進階
- 5個提高Node.js應用性能的技巧
- 浏覽器存儲及使用
作者資訊
作者來自力譜宿雲 LeapCloud 團隊_UX成員:王詩詩 【原創】
首發位址:https://blog.maxleap.cn/archives/985
王詩詩,前端新人,專職前端工作兩年。曾供職于AMI做底層軟體開發。喜歡分析H5代碼,追崇用簡單的CSS,建構精美動效,做前端之前,這些是業餘愛好。現任職于MaxLeap UX 組,負責MaxWon 的開發和維護。現熱衷于Real-time WebApp。
歡迎關注微信訂閱号:從移動到雲端