随着智能硬件的普及,手机,平板,PC甚至路边的电子广告牌,现代浏览器已经无处不在。在浏览器里编织出咱们本身的一片天地已经轻车熟路,可是这还不够,H5赋予了浏览器太多的新特性,等待咱们去使用。这篇文章介绍利用手机浏览器的罗盘API,在PC的浏览器实时地绘制一个3D盒模型。javascript
这种炫酷的玩法叫作“多屏互动”,就像是把手机当作游戏手柄,PC显示器当作电视机,不过这些都是在浏览器里实现的。css
先上效果图html
(测试机是刷了小米系统的裂了屏幕的HTC霹雳2+Chrome浏览器)前端
源码请戳这里:https://coding.net/u/OverTree...html5
本地测试过程:java
在PC上,使用命令 node index.js,自动打开项目主页。(请关闭ADsafe,若有虚拟机,请停用虚拟网卡)node
建立一个“房间”并自动进入“房间”。jquery
用手机扫描“房间”内任意位置的二维码。git
确保手机和PC能够相互PING通github
ADsafe是个很好用的去广告软件,可是会阻止本机IP访问,可能形成项目首页打不开,因此请先暂时关闭
本程序会自动获取本机IP,若是有虚拟网卡,IP地址可能获取不正确
一个物体在空间内的旋转体位,均可以用一个方向向量(x,y,z)和旋转角度(angle)来表示。也就是CSS3transform
的rotate3d(x,y,z,angle)
这个函数的4个参数。
想要在浏览器里方便的绘制一个立体模型的的旋转,重点就是利用手机浏览器的H5新特性去获取手机旋转状态的数据,而后转化成这4个参数。
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( 0 - evt.accelerationIncludingGravity.x , evt.accelerationIncludingGravity.y ).toFixed(2) / Math.PI * 180 ; }
这个 angle 就能够直接应用在DOM的CSS属性transform:rotate(angle deg)
上。
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/deviceo...
在这个页面内搜索 getQuaternion
另外我根据数学公式反求,写了一个四元数转(x,y,z,angle) 的函数 getAcQuaternion
代码以下:
var degtorad = Math.PI / 180; function getQuaternion( alpha, beta, gamma ) { //官方求四元数方法 var _x = beta ? beta * degtorad : 0; // beta value var _y = gamma ? gamma * degtorad : 0; // gamma value var _z = alpha ? alpha * degtorad : 0; // alpha value var cX = Math.cos( _x/2 ); var cY = Math.cos( _y/2 ); var cZ = Math.cos( _z/2 ); var sX = Math.sin( _x/2 ); var sY = Math.sin( _y/2 ); var sZ = Math.sin( _z/2 ); 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 = 2 * Math.acos(_w)/degtorad ; var x = _x / Math.sin(degtorad * rotate/2) || 0; var y = _y / Math.sin(degtorad * rotate/2) || 0; var z = _z / Math.sin(degtorad * rotate/2) || 0; 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[0],qu[1],qu[2],qu[3]); // rotate3d的参数已经有了,随你处理咯。我是把他送给服务器,交给PC,在PC上显示旋转 }
这里有个3D里的概念,摄像机位置。咱们的PC显示器就是一个摄像机。只能被动的从某一个角度展现拍摄的景象。正常状况下,手机所在平面应该和显示器所在平面平行,且垂直于地平面的角度。就比如是,摄像机正对着手机正面拍摄。
若是校准的时候手机并无垂直于地平面,摄像机的位置就不必定是正前方了。这时候展现的画面并非水平同步的了。
以下图所示,校准时,手机屏幕朝上。这时候摄像机位置就在天花板上了,你看到的成像就是俯视图。
同理,校准时,手机屏幕朝下,这时候摄像机的位置就是在地上,往上拍摄,你看到的成像就是仰视图。
总结起来就是:校准时,手机屏幕朝着哪里,摄像机就在那里拍摄着屏幕,一动不动。
demo的兼容性测试并不理想
在iOS平台上测试良好,且流畅。
在安卓平台上,除了chrome浏览器以外的浏览器,会出现各类问题,主要表如今罗盘数据不许确。
而chrome浏览器并无扫一扫功能,由于在国外并不流行这个玩意。因此在安卓平台上就很蛋疼,还要多装一个我查查,才能完总体验。
(若是出现旋转不许确的问题,能够尝试校准罗盘,大概就是拿着手机画8。百度一下方法有不少)
代码若是有兼容写法,或者有其余兼容问题请赐教,能够在coding上私信我(OverTree ),不胜感激。
PC浏览器的做用就是可以显示房间信息,建立房间。
显示房间,建立时间,参与人数,点击进入。
建立一个房间,成功后自动进入房间。
在房间内,接受服务器转发的手机端的消息,并做出相应动做,包括上线,校准,旋转,下线。
上线时,安排就坐(隐藏二维码,显示模型)
校准时,从新设置模型的显示角度。
旋转时,就旋转咯。
下线时,从新显示二维码(显示二维码,隐藏模型)
重点是房间里的事情。因此这里就只介绍进入房间发生的事吧。
首先房间参数要正确,至少有房间编号。
房间路由:
/room/[roomNumber]
roomNumber是一串16位随机字符串。
座位路由:
/room/[roomNumber]/[seatNumber]
var uri = win.location.pathname.split('/'),roomNumber; function initUrlData(){ if(uri.length>=3 && uri[1] == "room"){ roomNumber = uri[2]; document.title = "虚拟房间 "+ roomNumber + "号" return 1; }else{ window.location.href = "/index"; return 0; } } 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": 200, "color": "#3a3", "text": window.location.origin + "/room/" + roomNumber + "/" + (index+1) }); //这里用jQuery的插件,jquery-qrcode 按照座位路由初始化二维码 })
作为一名普通的前端人员,想要画一个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: 0; /*不加会歪*/ } .container { width: 300px; height: 200px; position: relative; perspective: 1200px; /*摄像机距离,设置小的的话,立方体显示会变形*/ } #box figure { display: block; position: absolute; border: 2px solid black; line-height: 200px; font-size: 40px; text-align: center; font-weight: bold; color: white; box-sizing: border-box; /*由于有2px宽的border,若是不设置为此值,那么每一个面的宽高都要少设置4个像素,才能对齐*/ } #box { width: 100%; height: 100%; position: absolute; transform-style: preserve-3d;/*这个很重要,默认是平面变形flat*/ } #box .front, #box .back { width: 300px; height: 200px; } #box .right, #box .left { width: 100px; height: 200px; left:100px; /*调整*/ } #box .top, #box .bottom { width: 300px; height: 100px; top:50px; /*调整*/ line-height:100px; } /*给每一个面上半透明的颜色*/ #box .front { background: hsla( 000, 100%, 50%, 0.7 ); } #box .back { background: hsla( 160, 100%, 50%, 0.7 ); } #box .right { background: hsla( 120, 100%, 50%, 0.7 ); } #box .left { background: hsla( 180, 100%, 50%, 0.7 ); } #box .top { background: hsla( 240, 100%, 50%, 0.7 ); } #box .bottom { background: hsla( 300, 100%, 50%, 0.7 ); } #box .front { /*这个距离乘以2为先后面的距离*/ transform: translateZ( 50px ); } #box .back { /*front面沿着x轴旋转180度,作后面*/ transform: rotateX( -180deg ) translateZ( 50px ); } #box .right { /*这个距离乘以2为左右面的距离*/ transform: rotateY( 90deg ) translateZ( 150px ); } #box .left { /*front面沿着y轴旋转90度,作侧面*/ transform: rotateY( -90deg ) translateZ( 150px ); } #box .top { /*这个距离乘以2为长方体高*/ transform: rotateX( 90deg ) translateZ( 100px ); } #box .bottom { /*front面沿着x轴旋转90度,作底面*/ transform: rotateX( -90deg ) translateZ( 100px ); } </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的定义不能和手机传感器一致。
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-3d; }
PC端的兼容性就好多了,只要是现代H5浏览器基本上没有兼容性问题。
这个服务只作临时数据的保存和消息转发。
临时数据:好比,各端的webSocket链接句柄,房间信息等,我把它们放在global全局对象下,就比如是共享内存,访问方便,速度快。
global.ShareMem = { rooms:{ "12345678":{ //房间号作为key,方便查找 player:[{socket:connection,place:place}], //手机端数组:链接句柄,座位号 projector:[], //PC端数组 id:"12345678", startTime:Date.now(), maxplayer:2, //最多座位数 type:"ddd" //房间类型 } } };
若是您是nodejs的大神,或者在用koajs、express等nodejs框架,请跳过本大节。由于我用原生的nodejs写了一遍webServer,虽然重复造轮子很差,可是复习复习webServer的基本知识,仍是不错的,本节适合新手入门。
包含知识点:header解析,静态文件查找,gzip,文件hash计算,状态码。
/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
基本规则是这样的,搭建静态服务器,静态资源正常读取返回,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<=2){ pathName += "/"; //访问根目录 }else if(params[1]=="api"){ //访问以api开头 parseAPI(params,req,res); //功能函数 return ; }else{ pathName = params[1]+".html"; } }
我在这里作了一个简单的框架,在API目录或者socketAPI目录下新增js文件,一个js文件对应一个处理函数,而后在funMap.js中聚合为一个Map,方便查找函数,也容易隔离和修改函数名。
var funMap = { "room":require("./room"), "changeName":require("./xxx"), "changeName2":require("./xxxyyy") }; module.exports = funMap;
客户端访问时就能够经过 /api/[functionName] 来访问想要的服务了。
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[2]]){ funMap[URIarray[2]](connection); }else{ connection.send("{err:Function Not Found!!}"); } }
每当有ws链接进来,都有相似文件描述符的id来区分每一个不一样的链接。connection._ultron.id
用它能够区分本身与别人的链接,颇有用。
//消息格式 function msgPack(){ return JSON.stringify({ "who":arguments[0], // Mobile , PC "place":arguments[1], // 座位 "dowhat":arguments[2], // "connect","ready","message","lost" "content":arguments[3]||"" // 内容 }) } //以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 == 0){ connection.close(); connection.emit("close"); clearInterval(keeplifeHandler); } try{ lastkeeplife = 0; connection.send("{}"); }catch(e){ console.log("keep live error! "+ e +"\n\n"); connection.close(); connection.emit("close"); clearInterval(keeplifeHandler); } },5000) 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,1); return false; } }) }else{ room.player.forEach(function(item,index){ if(item.socket === connection){ room.player.splice(index,1); return false; } }) } //发送掉线消息 boradCast( room, msgPack(platform,place,"lost") , connection ); });
iOS设备若是锁屏,会发送断开信息给服务器,而安卓不会。想要断开连接,必须等到默认120秒超时后关闭。
ws初始化时并无提供初始化timeout的配置。经过修改
ws._server.timeout = 1000;//1秒超时
并不会生效。问题来了,怎么修改才能设置超时时间呢?
目前只能用上述比较捉急的方法来及时断开掉线设备。
多屏互动已经不是新鲜的东西了,我作这个Demo仍是受chrome实验室一个叫作【光剑出鞘】的项目的启发。由于体验时须要手机端和PC同时翻-墙,致使体验差,而后本身才想作一个。作出来的时候感受好酷炫,好神奇,好兴奋。
后续仍是有不少能够拓展和改进的,但愿最终能够变为一个成熟的产品,而不是仅仅止步于Demo。
做者信息
做者来自力谱宿云 LeapCloud 团队_UX成员:王诗诗 【原创】
首发地址:https://blog.maxleap.cn/archi...
王诗诗,前端新人,专职前端工做两年。曾供职于AMI作底层软件开发。喜欢分析H5代码,追崇用简单的CSS,构建精美动效,作前端以前,这些是业余爱好。现任职于MaxLeap UX 组,负责MaxWon 的开发和维护。现热衷于Real-time WebApp。
欢迎关注微信订阅号:MaxLeap_yidongyanfa