• WebVR


    WebVR

    主要面向Web前端工程师,需要一定Javascript及three.js基础;
    本文主要分享内容为基于three.js开发WebVR思路及碰到的问题;
    有兴趣的同学,欢迎跟帖讨论。

    目录:
    一、项目体验
    1.1、项目简介
    1.2、功能介绍
    1.3、游戏体验
    二、技术方案
    2.1、为什么使用WebVR
    2.2、常用的WebVR解决方案
    2.2.1、Mozilla的A-Frame方案
    2.2.2、three.js及webvr-polyfill方案
    三、技术实现
    3.1、知识储备
    3.2、实现步骤
    3.3、工作原理
    四、技术难点
    4.1、程序与用户共同控制摄像头
    4.2、多重蒙板贴图
    4.3、镜头移动
    4.4、3d自适应长度文字提示
    4.5、unity3d地形导出
    4.6、3dmax动画导出问题
    五、完整的源代码及相应组件


    一、项目体验
    1.1、项目简介:
    1.1.1、名称:
    “重历阿尔特里亚”——龙之谷手游手首发ChinaJoy2016预热VR小游戏


    1.1.2、开发背景:
    基于龙之谷手游具备的3D属性,全景视角体验,以及ChinaJoy首发的线下场景,我们和品牌讨论除了基于VR的线下体验项目。由于基于Web技术较好的兼容性、开发的高效性,我们采用了WebVR技术来实现整个体验。

    1.1.3、使用WebVR优势:
    1.1.3.1、普通web前端工程师可以参与VR应用开发,降低了开发门槛;
    1.1.3.2、跨设备终端、跨操作系统、跨APP载体;
    1.1.3.3、开发快速、维护方便、随时调整、传播便捷;
    1.1.3.4、浏览器即可体验,无需安装。

    1.2、功能介绍
    基于游戏内3D场景、人物和道具模型,通过WebGL框架three.js开发的VR小游戏,在ChinaJoy龙之谷手游展台给玩家提供线下VR互动体验,并在后续应用于线上营销传播。不具备VR眼镜设备的用户可选择普通模式进行互动体验。

    1.3、游戏体验
    如果你身边正好有VR眼镜,请选择VR模式体验;如果没有,请选择普通模式。
    需要说明的是,由于本次应用针对线下场景,而合作方三星提供了最新的S7手机和GearVR设备,所以项目只针对S7做了体验优化,所以可能部分手机会有卡顿或者3D模型错乱的情况。

    你可以扫描如下二维码或打开http://dn.qq.com/act/vr/进行体验:

    二、技术方案
    2.1、为什么是时候尝试WebVR了?
    2.1.1、时机慢慢成熟,我们通过几件事件即可感知:
    2015年初,Mozilla在firefox nightly增加了对WebVR的支持;
    2015年底,MozVR团队推出开源框架A-Frame,能过HTML标签,即可创建VR网页;
    2015年底,Egret3D发布,开发团队称将在以后版本中实现WebVR的支持;
    2016年初,Google与Mozilla联合创建WebVR标准;
    2016年6月,Google计划将整个Chrome浏览器搬进VR世界中。
    2.1.2、WebVR开发成本更低。
    2015年VR硬件迅速发展,但时至今日,VR内容还是稍显单薄。原因在于,VR开发成本过高,而WebVR依托于WebGL及类似threeJS等框架,大大降低开发者进入VR领域的门槛。
    2.1.3、Web自身的优势
    上文中已有提及,依托也Web,具有不需安装、便于传播、便于快速迭代等特点。

    2.2、目前阶段,常用的WebVR解决方案:
    2.2.1、A-frame
    介绍:Mozilla的开源框架,通过定制HTML元素即可构建WebVR方案的框架,适用于没有webGL与threeJS基础的初学者。
    优点:基于threeJS的封装,通过特定的标签就能够快速创建VR网页;
    缺点:所提供的组件有限,难以完成较复杂的项目。
    实例:
    2.2.1.1、创建一个简单的场景。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <meta name="description" content="Composite — A-Frame">
    <script src="../aframe.js"></script>
    </head>
    <body>
        <a-scene>
            <!-- 环境光. -->
            <a-entity light="type: ambient; color: #888"></a-entity>
            <a-entity position="0 2.2 4">
            <!-- 添加相机 -->
            <a-entity camera look-controls wasd-controls>
                <!-- 添加圆环 -->
                <a-entity cursor
                geometry="primitive: ring; radiusOuter: 0.015; radiusInner: 0.01; segmentsTheta: 32" material="color: #283644; shader: flat" raycaster="far: 30" position="0 0 -0.75"></a-entity>
            </a-entity>
            </a-entity>
        </a-scene>
    </body>
    </html>

    源码讲解:
    如上简单的几个标签,即可构建一个包含灯光、相机、跟随相机的物体的场景,其余事情,都将由A-frame进行解析,具体标签与属性不多作讲解,可以参考 A-frame DOC

    2.2.1.1、加载一个由软件(比如3dmax)导出的模型。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    <!DOCTYPE html>
    <html>
    <head>
    <meta charset="utf-8">
    <meta name="description" content="Composite — A-Frame">
    <script src="../aframe.js"></script>
    <script>
        AFRAME.registerComponent('json-model', {
            schema: {
                type: 'src'
            },
            init: function () {
                this.loader = new THREE.JSONLoader();
            },
            update: function () {
                var mesh = this.el.getOrCreateObject3D('mesh', THREE.Mesh);
                this.loader.load(this.data, function (geometry) {
                mesh.geometry = geometry;
                });
            }
        });
    </script>
    </head>
    <body>
    <a-scene>
        <a-assets>
            <a-asset-item id="sculpture" src="data/building-ground.js"></a-asset-item>
        </a-assets>
        <a-entity id="car" json-model="#sculpture"  position="0 0 0" scale="5 5 5" rotation="0 45 0" material="src: url(cross-domain/skin/xianxiasq_zhujianqiangmian_001.png)"></a-entity>
    </a-scene>
    </body>
    </html>

    源码讲解:
    这个例子主要演示,A-Frame如何添加组件,对,因为A-Frame现阶段组件太少,加载自定义模式需要自己扩展组件。而组件添加需要three.js基础。
    so,A-Frame出发点是非常美好的,学习几个简单的标签及属性,即可以搭建3d/webvr场景,但是现实却是目前它还并不成熟,并且伴随着A-Frame主设计师跳槽到Google,所以我很早就放弃这个方案了。

    2、基于threeJS与webVR组件,事实上,A-frame就是基于这两者的封装。
    优点:可以完成复杂项目,可以结合原生的webGL;
    缺点:需要掌握threeJS,需要了解webGL,学习成本较高。

    在本项目中,选用的就是这个方案,在下章节中,将会进行详细介绍。

    三、技术实现
    3.1、知识储备:
    three.js(掌握)、webGL(了解)、javascript
    对three.js没有基础的同学,可以移步至 Three.js实例教程

    3.2、实现步骤:
    简单来说,完成一个WebVR应用,需要以下三个步骤:
    3.2.1、搭建场景

    如上图与示:
    首先我们需要载入我们的资源,这些资源包括地形、角色、动画、及辅助元素;
    然后创建我们需要的元素,比如灯光、相机、天空等;
    然后完成主业务逻辑。

    3.2.2、交互
    即用户的动作输入,这些动作包括:
    位置移动、旋转、视线焦点、声音、甚至全身所有关节动作。
    当然,当前我们可利用的硬件设备有限,手机自身可利用的如陀螺仪、罗盘、听筒。其余辅助设备常用如Leap Motion、Kinect等。
    更多的额外设备意识着更高的使用成本,在本案例中使用的到的动作输入信息:
    用户当前方向,由VRControls.js与webvr-polyfill.js实现完成;
    用户视角焦点,完成按钮点击、攻击等动作,通过跟随相机的物体检测碰撞来完成。

    3.2.3、分屏

    如上图所示,为让用户更具沉侵感,通常会根据用户瞳距将屏幕分割成具有一定视差的两部分,勿需担心,这部分工作由VREffect.js来完成。

    3.3、工作原理
    上节中提到了webvr相关组件,本来我们可以简单利用它提供的接口就可以完成,但肯定还是有同学会好奇,它的工作原理是怎样的呢。

    这得从Mozilla与Google 2016年初联手推出的WebVR API提案开始,WebVR Specification,该提案给VR硬件定义了专门定制的接口,让开发者能够构建出沉浸感强,舒适度高的VR体验。但由于该标准还处于草案阶段,所以我们开发需要WebVR Polyfill,这个组件不需要特定浏览器,就可以使用WebVR API中的接口。
    所以我们只需要在项目中,引入webvr-polyfill.js及VRControls、VREffect两个类,并调用即可。

    1
    2
    vrEffect = new THREE.VREffect(renderer);
    vrControls = new THREE.VRControls(camera);

    webvr-polyfill基于普通浏览器实现了WebVR API 1.0功能;
    VRControls更新摄像头信息,让用户以第一人称置于场景中;
    VREffect负责分屏。

    四、技术难点

    4.1、程序与用户共同控制摄像头
    当程序在自动移动镜头的过程中,允许用户四处观察,这时候需要一个辅助容器共同控制镜头旋转与移动。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 添加摄像机
    camera = new THREE.PerspectiveCamera(60, size.w / size.h, 1, 10000);
    camera.position.set(0, 0, 0);
    camera.lookAt(new THREE.Vector3(0,0,0));
     
    // 辅助镜头移动
    dolly  = dolly = new THREE.Group();
    dolly.position.set(10, 40, 40);
    dolly.rotation.y = Math.PI/10;
    dolly.add(camera);
    scene.add(dolly);

    4.2、多重蒙板贴图


    如上图所示,该地形由三种贴图通过蒙板共同合成,这时候我们需要使用自定义Shader来实现,由rbg三个通道控制显示。
    核心代码(片元着色器):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    fragmentShader: [
        'uniform sampler2D texture1;',
        'uniform sampler2D texture2;',
        'uniform sampler2D texture3;',
        'uniform sampler2D mask;',
        'void main() {',
            'vec4 colorTexture1 = texture2D(texture1, vUv* 40.0);',
            'vec4 colorTexture2 = texture2D(texture2, vUv* 60.0);',
            'vec4 colorTexture3 = texture2D(texture3, vUv* 20.0);',
            'vec4 colorMask = texture2D(mask, vUv);',
            'vec3  outgoingLight = vec3( colorTexture1.rgb*colorMask.r + colorTexture2.rgb *colorMask.g + colorTexture3.rgb *colorMask.b ) * 0.6;',
            'gl_FragColor =  vec4(outgoingLight, 1.0);',
        '}'
    ].join(" ")

     完整代码(添加three.js灯光,雾化):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    70
    71
    72
    73
    74
    75
    76
    77
    78
    79
    80
    81
    82
    83
    84
    85
    86
    87
    88
    89
    90
    91
    92
    93
    94
    95
    96
    97
    98
    // 合成材质
    var map1 = texLoader.load('cross-domain/skins/foor_stone02.png' );
    var map2 = texLoader.load('cross-domain/skins/green_wet09.png');
    var map3 = texLoader.load('cross-domain/skins/stone_dry02.png');
     
    // 自定义复合蒙板shader
    THREE.FogShader = {
        uniforms: lib.extend( [
     
            THREE.UniformsLib[ "fog" ],
            THREE.UniformsLib[ "lights" ],
            THREE.UniformsLib[ "shadowmap" ],
            {
                'texture1': { type: "t", value: map1},
                'texture2': { type: "t", value: map2},
                'texture3': { type: "t", value: map3},
                'mask': { type: "t", value: texLoader.load('cross-domain/skins/mask.png')}
            }
        ] ),
        vertexShader: [
            "varying vec2 vUv;",
            "varying vec3 vNormal;",
            "varying vec3 vViewPosition;",
     
            THREE.ShaderChunk[ "skinning_pars_vertex" ],
            THREE.ShaderChunk[ "shadowmap_pars_vertex" ],
            THREE.ShaderChunk[ "logdepthbuf_pars_vertex" ],
     
            "void main() {",
     
                THREE.ShaderChunk[ "skinbase_vertex" ],
                THREE.ShaderChunk[ "skinnormal_vertex" ],
     
     
                "vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );",
     
                "vUv = uv;",
                "vNormal = normalize( normalMatrix * normal );",
                "vViewPosition = -mvPosition.xyz;",
     
                "gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );",
                THREE.ShaderChunk[ "logdepthbuf_vertex" ],
            "}"
        ].join(' '),
     
        fragmentShader: [
            'uniform sampler2D texture1;',
            'uniform sampler2D texture2;',
            'uniform sampler2D texture3;',
            'uniform sampler2D mask;',
     
            'varying vec2 vUv;',
            'varying vec3 vNormal;',
            'varying vec3 vViewPosition;',
            // "vec3 outgoingLight = vec3( 0.0 );",
            THREE.ShaderChunk[ "common" ],
            THREE.ShaderChunk[ "shadowmap_pars_fragment" ],
            THREE.ShaderChunk[ "fog_pars_fragment" ],
            THREE.ShaderChunk[ "logdepthbuf_pars_fragment" ],
     
            'void main() {',
                THREE.ShaderChunk[ "logdepthbuf_fragment" ],
                THREE.ShaderChunk[ "alphatest_fragment" ],
                 
                'vec4 colorTexture1 = texture2D(texture1, vUv* 40.0);',
                'vec4 colorTexture2 = texture2D(texture2, vUv* 60.0);',
                'vec4 colorTexture3 = texture2D(texture3, vUv* 20.0);',
                'vec4 colorMask = texture2D(mask, vUv);',
     
                'vec3 normal = normalize( vNormal );',
                'vec3 lightDir = normalize( vViewPosition );',
     
                'float dotProduct = max( dot( normal, lightDir ), 0.0 ) + 0.2;',
     
                'vec3  outgoingLight = vec3( colorTexture1.rgb*colorMask.r + colorTexture2.rgb *colorMask.g + colorTexture3.rgb *colorMask.b ) * 0.6;',
     
     
                THREE.ShaderChunk[ "shadowmap_fragment" ],
                THREE.ShaderChunk[ "linear_to_gamma_fragment" ],
                THREE.ShaderChunk[ "fog_fragment" ],
     
                // 'gl_FragColor = vec4( colorTexture1.rgb*colorMask.r + colorTexture2.rgb *colorMask.g + colorTexture3.rgb *colorMask.b, 1.0 )  + vec4(outgoingLight, 1.0);',
                // 'gl_FragColor = outgoingLight;',
                'gl_FragColor =  vec4(outgoingLight, 1.0);',
            '}'
        ].join(" ")
     
    };
    THREE.FogShader.uniforms.texture1.value.wrapS = THREE.FogShader.uniforms.texture1.value.wrapT = THREE.RepeatWrapping;
    THREE.FogShader.uniforms.texture2.value.wrapS = THREE.FogShader.uniforms.texture2.value.wrapT = THREE.RepeatWrapping;
    THREE.FogShader.uniforms.texture3.value.wrapS = THREE.FogShader.uniforms.texture3.value.wrapT = THREE.RepeatWrapping;
     
    var material = new THREE.ShaderMaterial({
        uniforms        : THREE.FogShader.uniforms,
        vertexShader    : THREE.FogShader.vertexShader,
        fragmentShader  : THREE.FogShader.fragmentShader,
        fog: true
    });

    3、 镜头移动(依赖Tween类)
    功能函数:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    cameraTracker: function(paths){
        var tweens = [];
        for(var i = 0; i < paths.length; i++) {
            (function(i){
                var tween = new TWEEN.Tween({pos: 0}).to({pos: 1}, paths[i].duration || 5000);
                tween.easing(paths[i].easing || TWEEN.Easing.Linear.None);
                tween.onStart(function(){
                    var oriPos =  dolly.position;
                    var oriRotation = dolly.rotation;
                    this.oriPos = {x: oriPos.x, y: oriPos.y, z: oriPos.z};
                    this.oriRotation = {x: oriRotation.x, y: oriRotation.y, z: oriRotation.z};
                });
                tween.onUpdate(paths[i].onupdate || function(){
                    if(paths[i].pos) {
                        dolly.position.x = this.oriPos.x + this.pos * (paths[i].pos.x -  this.oriPos.x);
                        dolly.position.y = this.oriPos.y + this.pos * (paths[i].pos.y -  this.oriPos.y);
                        dolly.position.z = this.oriPos.z + this.pos * (paths[i].pos.z -  this.oriPos.z);       
                    }
                    if(paths[i].rotation) {
                        dolly.rotation.x = this.oriRotation.x + this.pos * (paths[i].rotation.x -  this.oriRotation.x);
                        dolly.rotation.y = this.oriRotation.y + this.pos * (paths[i].rotation.y -  this.oriRotation.y);
                        dolly.rotation.z = this.oriRotation.z + this.pos * (paths[i].rotation.z -  this.oriRotation.z);
                    }
                });
                tween.onComplete(function(){
                    paths[i].fn && paths[i].fn();
                    var fn = tweens.shift();
                    fn && fn.start();
                });
                tweens.push(tween);
            })(i);
        }
        tweens.shift().start();
    }

    调用:

    1
    2
    3
    lib.cameraTracker([
        {'pos': { x: -45,y: 5, z: -38},'rotation': {x: 0, y: -1.8, z: 0},  'easing': TWEEN.Easing.Cubic.Out,'duration':4000}
    ]);

     4、自适应长度文字提示
    根据文字长度生成canvas作为贴图到Sprite对象。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    69
    hint = function(text, type, posY, fadeTime){
        var chinense = text.replace(/[u4E00-u9FA5]/g, '');
        var dbc = chinense.length;
        var sbc = text.length - dbc;
        var length = dbc * 2 + sbc;
        var fontsize = 40;
        var textWidth = fontsize* length / 2;
        posY = posY || 0.3;
        type = type || 1;
        fadeTime = fadeTime === window.undefined ? 500 : fadeTime;
     
        if(text == 'sucess' || text == 'fail') {
            text = ' ';
        }
     
        var canvas = document.createElement("canvas");     
        var width = 1024, height = 512;
        canvas.width = width;
        canvas.height = height;
        var context = canvas.getContext('2d');
     
        var imageObj = document.querySelector('#img-hint-' + type);
         
     
        context.drawImage(imageObj, width/2 - imageObj.width/2, height/2 - imageObj.height/2);
        context.font = 'Bold '+ fontsize +'px simhei';
        context.fillStyle = "rgba(255,255,255,1)";
        context.fillText(text, width/2-textWidth/2, height/2+15);          
     
     
        var texture = new THREE.Texture(canvas);
        texture.needsUpdate = true;
     
        var mesh;
        var material = new THREE.SpriteMaterial({
            map: texture,
            transparent: true,
            opacity: 0
        });
        mesh = new THREE.Sprite(material);
        mesh.scale.set(width/400, height/400, 1);
        mesh.position.set(0, posY, -3);
        camera.add(mesh);  
     
        var tweenIn = new TWEEN.Tween({pos: 0}).to({pos: 1}, fadeTime);
        tweenIn.onUpdate(function(){
            material.opacity = this.pos;
        });
        if(fadeTime === 0) {
            material.opacity = 1;
        else {
            tweenIn.start();
        }
         
     
        var tweenOut = new TWEEN.Tween({pos: 1}).to({pos: 0}, fadeTime);
        tweenOut.onUpdate(function(){
            material.opacity = this.pos;
        });
        tweenOut.onComplete(function(){
            camera.remove(mesh);
        });
        tweenOut.fadeOut = tweenOut.start;
        tweenOut.remove = function(){
            camera.remove(mesh);
        }
     
        return tweenOut;
    };

    5、unity地形导出
    5.1、首先将unity地形导出为obj

    5.2、然后导入3dmax,使用ThreeJSExporter.ms导出为js格式。

    6、3dmax动画导出问题
    6.1、动画导出错误
    通常是对象为可编辑多边形,需要转换成网格对象。

    操作步骤:
    6.1.1、选择对象,右键转换为可编辑网络;
    6.1.2、选择蒙皮修改器,重新蒙皮;
    6.1.3、点击蒙皮修改器下的骨骼 > 添加,添加原有的骨骼。
    6.2、动画导出错乱
    很容易让人以为是权重出问题了,但就我自己多个项目动画导出的经验来看,大部分出现在骨骼添加上。在3dmax及unity中,不添加根节点往往不影响动画执行,但导出到three.js,需要添加根节点。如果问题还存在,则仔细观察是哪个骨骼引起的,多余骨骼或缺少骨骼都可能引起动画错乱。

    五、完整的源代码及相应组件
    点击下载
    main.js - 完整的源代码
    tween.min.js - 动画类
    OrbitControls.js - 视图控制器,旋转、移动、缩放场景,方便调试
    audio.min.js - motion音频组件,解决自动播放音频问题
    其余vr相关组件上文已有介绍

     
    分类: three.js
  • 相关阅读:
    java web
    java web
    java
    周末总结7
    java
    java
    java
    java
    java web
    java
  • 原文地址:https://www.cnblogs.com/Leo_wl/p/5767785.html
Copyright © 2020-2023  润新知