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(
"\n"
)
|
完整代碼(添加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(
'\n'
),
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(
"\n"
)
};
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相關組件上文已有介紹