在ThreeJS简介中已经介绍了如何使用ThreeJS框架显示一个简单的对象,并为其赋予了一个简单的默认材质。这一章将以无规则的移动国际象棋为DEMO简单介绍一下ThreeJS框架的模型读取、天空盒子、使用动画引擎Tween.js来创建动画,以及简要介绍一下广度搜索算法。
1. 模型的读取
三维模型有数百种文件格式,每种格式都有不同的用途、各种功能和不同的复杂性。尽管three.js提供了许多加载器支持的模型格式非常多,但是选择正确的格式和工作流程将节省时间。有一些格式很难使用,对于实时显示效率低下,或者目前还没有完全支持,选择不好模型格式可能为后期的开发带来很多麻烦。因此官方推荐大多数用户使用GLTF格式模型,并提供了各种的转换工具,可以说官方对GLTF格式模型支持的是最好的。
1.1. glTF格式简介
glTFTM(GL TransmissionFormat图形语言交换格式)是一种免版税(royalty-free)的规范,由Khronos Group管理(Khronos Group还管理着OpenGL系列、OpenCL等重要的行业标准)。glTF的设计是面向实时渲染应用的,尽量提供可以直接传输给图形API的数据形式,不再需要二次转换,用于通过应用程序高效传输和加载3D场景和模型。GLTF最小化了3D资源的大小,以及解包和使用这些资产所需的运行时处理。glTF对OpenGL ES、WebGL非常友好,作为一个标准,自2015年10月发布(glTF 1.0)以来,已经得到了业界广泛的认可,glTF目前最新版本为2.0已于2017年6月正式发布。GLTF具体的数据存储格式可以去官方网站上看:https://www.khronos.org/gltf/,大概就是相对于XML的JSON存储方式。
glTF有很多的转换、导入、导出工具供用户选择,详细的可以在官方网站查询,下面简要介绍一个主要的工具:
- glTF-Blender-IO by the Khronos Group,用于Blender上的导入导出工具,该工具在Blender2.80开始已经被内置了。
- COLLADA2GLTF by the Khronos Group,用于COLLADA上的转化工具。
- FBX2GLTF by Facebook,用于将FBX格式模型转换为glTF的工具。
- OBJ2GLTF by Analytical Graphics Inc,用于将obj格式模型转换为glTF的工具。
- 3DS Max Exporter ,用于3DMAX 2015或更高版本的导出工具,使用BabylonJS plugin。
- Maya Exporter,用于Maya2018或更高版本的导出工具,使用BabylonJS plugin。
1.2. glTF格式的读取
在ThreeJS中只有一少部分载入器是内置在three.js中的,比如ObjectLoader,其他都需要手动添加载入器。
// global script
// commonjs
var THREE = window.THREE = require('three');
require('three/examples/js/loaders/GLTFLoader');
一旦导入了加载程序,就可以向场景中添加模型了。不同加载程序的语法有所不同,使用加载器前,请检查该加载程序的示例和文档。对于GLTF,基本用法是:
var loader = new THREE.GLTFLoader();
loader.load( 'path/to/model.glb', function ( gltf ) {
scene.add( gltf.scene );
}, undefined, function ( error ) {
console.error( error );
} );
浏览器兼容性:GLTFLoader依赖于IE6 中不支持的ES6 Promises。要在IE11中使用加载程序,必须 包含一个 提供Promise替换的polyfill。
官方样例——地址
2. 天空盒子
有时候布置一个3D场景,没有美丽的背景会使场景显得非常单调,因此在没有特殊需求的场景并且想要节省性能的条件下,使用天空盒子是最好的选择。在官方GLTF模型读取的样例中,就是使用了这样的天空盒子,并且将天空盒子作为了模型的Env Map,这样再模型反光是可以考到反光出来的天空盒子,使人感觉更加真实。
2.1. 天空盒子简介
在实时渲染中,如果要绘制非常远的物体,例如远处的山、天空等,随着观察者的距离的移动,这个物体的大小是几乎没有什么变化的,想象一下远处有一座山,即使人走进十米、百米、甚至千米,这座山的大小也是几乎不怎么改变的,这个时候可以考虑采用天空盒技术。
所谓的天空盒其实就是将一个立方体展开,然后在六个面上贴上相应的贴图,如上图所示。
在实际的渲染中,将这个立方体始终罩在摄像机的周围,让摄像机始终处于这个立方体的中心位置,然后根据视线与立方体的交点的坐标,来确定究竟要在哪一个面上进行纹理采样。具体的映射方法为:设视线与立方体的交点为(x,y,z),在x、y、z中取绝对值最大的那个分量,根据它的符号来判定在哪个面上采样。
然后让其他两个分量都除以最大分量的绝对值,这样就让另外两个分量都映射到了[0,1]内,然后就可以直接在对应的纹理上做纹理映射就行了,这个方法就是所谓的Cube Map,是天空盒方法的核心。
天空盒的原理非常简单,下面来探讨关于天空盒的几个细节问题。
z = w
在投影变换之后,会做一步透视除法,即让四元向量的所有分量都除以它的W分量,从而使视锥体内的区域的x、y映射到[−1,1],z映射到[0,1],从而根据透视除法之后的x、y、z的范围直接剔除掉那些不可见的顶点,如果令z=w,就表示透视除法后的z=1,也就是让天空盒始终处于远平面的位置,从而让它被之后所有要绘制的物体遮挡住。由于z=1,要修改深度测试的比较函数,如果比较函数是小于的话,由于深度缓冲的最大值本来就是1,无法通过深度测试的比较函数,最终天空盒是没办法被绘制出来的。
消隐问题
由于摄像机位于物体的内部,这个时候消隐的设置尤为重要,模型本身是没有发生变化的,它的法线始终朝向外部,那么视线与法线的夹角就变成了锐角,如果采用背面消隐,整个模型就会都被剔除掉,所以在绘制天空盒的时候,要么取消消隐,要么将消隐设置为正面消隐。
天空盒模型大小的限制
原则上模型的大小对于最终的结果没有影响,因为在模型增大的时候,虽然它的面是变大了,但它离视锥也变远了,因此最终视野内的大小是保持不变的,但是一定要保证模型始终处在视锥体的内部,即让模型离摄像机最远的点到摄像机的距离不超过摄像机到远平面的距离,对于一个立方体模型来说,离中心最远的点显然是它的顶点,
a为立方体的边长,由
得
因此天空盒的模型边长有一个上界,在这个最大值以内都是可以的。
2.2. 设置天空盒子
在ThreeJS中设置天空盒子非常简单,首先需要载入一个CubeTexture,需要6张图片。
var loader = new THREE.CubeTextureLoader();
loader.setPath( 'textures/cube/pisa/' );
var textureCube = loader.load( [ 'px.png', 'nx.png', 'py.png', 'ny.png', 'pz.png', 'nz.png' ] );
var material = new THREE.MeshBasicMaterial( { color: 0xffffff, envMap: textureCube } );
scene.background = textureCube;
上面的代码读取了一个天空盒子,然后设置了场景的背景为这个盒子,并将一个材质的envMap设置为这个盒子,这样当这个材质赋予某一对象时,就可以有官方样例中的效果了。
3. 动画引擎Tween.js
TweenJS Javascript库提供了一个简单但强大的渐变界面。它支持渐变的数字对象属性&CSS样式属性,并允许您链接补间动画和行动结合起来,创造出复杂的序列。
3.1. 简单的补间动画
这个渐变将渐变目标alpha属性用一秒从0渐变到1,然后调用handleComplete函数。
target.alpha = 0;
createjs.Tween.get(target).to({alpha:1}, 1000).call(handleComplete);
function handleComplete() {
//渐变完成执行
}
3.2. 参数和范围
Tween总是提供一个call()伴随着参数和/或一个范围。如果没有传递范围,那么称为匿名函数(正常JavaScript行为)。 在面向对象的风格发展,范围是有用的维护范围。
createjs.Tween.get(target).to({alpha:0}).call(handleComplete, [argument1, argument2], this);
3.3. 可链式编程的补间动画
这个渐变将会先等待0.5秒,渐变目标的alpha属性从0到1,并且visible属性从true变为false,这个过程用时1秒,最后调用handleComplete函数。
target.alpha = 1;
createjs.Tween.get(target).wait(500).to({alpha:0, visible:false}, 1000).call(handleComplete);
function handleComplete() {
//渐变完成执行
}
3.4. Ease类
这个Ease类提供了一个缓动动画函数集合,在使用TweenJs的使用中。它不使用标准的4参数缓动。相反,它使用了一个参数,表示当前线性比例(0,1)的渐变。
大多数方法缓解可以直接通过缓动函数:
Tween.get(target).to({x:100}, 500, Ease.linear);
然而,方法从“get”开始将返回一个基于参数值的缓动函数:However, methods beginning with "get" will return an easing function based on parameter values:
Tween.get(target).to({y:200}, 500, Ease.getPowIn(2.2));
请参见更多不同的缓动类型在TweenJS.com上的概述。由罗伯特·彭纳方程派生而来的.
具体渐变方式列表如下
}
1 | 2 | 3 | 4 |
---|---|---|---|
backIn | backInOut | backOut | bounceIn |
bounceInOut | bounceOut | circIn | circInOut |
circOut | cubicIn | cubicInOut | cubicOut |
elasticIn | elasticInOut | elasticOut | get |
getBackIn | getBackInOut | getBackOut | getElasticIn |
getElasticInOut | getElasticOut | getPowIn | getPowInOut |
getPowOut | linear | none | ·quadIn |
quadInOut | quadOut | quartIn | quartInOut |
quartOut | quintIn | quintInOut | quintOut |
sineIn | sineInOut | sineOut |
3.5. 浏览器支持
TweenJS会在现代浏览器工作。TweenJS在IE8或者更早的版本上,使用一个旧版本的PreloadJS(0.4.1和更早的版本)。
4. 广度优先搜索算法
广度优先搜索算法(Breadth-First Search),简称BFS,是一种图搜索算法。主要思想史从起点卡斯hi,沿着图的边一层一层的遍历图,如果发现目标,则演算终止,基本的BFS是一种穷搜算法。
4.1. 实现
我们已下图为例,V2作为起点V6为终点。
队列初始为起点{V2},
- 首先沿着队列中index=0的节点的边找到所有相邻节点,第一层搜索将V4、V5、V1加入队列{V2,V4,V5,V1}
- 随后沿着index=1的节点的边找到起相邻节点V2、V8,其中V2已经在队列中因此只加入V8,{V2,V4,V5,V1,V8}
- 沿着index=index+1的节点的边重复2
- 当找到目标时递归过程返回(或结束循环),不再搜索。
- 当index=队列长度时已搜索完全部可能路径,未找到目标,说明无可达到路径。
模拟过程:
index=0,V2=>add(V4,V5,V1)=>{V2,V4,V5,V1}=>way{(V2),(V2,V4),(V2,V5),(V2,V1)}
index=1,V4=>add(V8)not(V2)=>{V2,V4,V5,V1,V8}=>way{(V2),(V2,V4),(V2,V5),(V2,V1),(V2,V4,V8)}
index=2,V5=>not(V2,V8)=>{V2,V4,V5,V1,V8}=>way{(V2),(V2,V4),(V2,V5),(V2,V1),(V2,V4,V8)}
index=3,V1=>NOT(V2)add(V3)=>{V2,V4,V5,V1,V8,V3}=> way{(V2),(V2,V4),(V2,V5),(V2,V1),(V2,V4,V8),(V2,V1,V3)}
index=4,V8=>not(V4,V5)=> {V2,V4,V5,V1,V8,V3}=> way{(V2),(V2,V4),(V2,V5),(V2,V1),(V2,V4,V8),(V2,V1,V3)}
index=5,V3=>isTarget(V6)=>return way(index)+V6=>the way is (V2,V1,V3,V6)
4.2. 空间复杂度
空间复杂度有两种理论:
- O(|V| + |E|),其中 |V| 是节点的数目,而 |E| 是图中边的数目。
- O(B^M),其中 B 是最大分支系数,而 M 是树的最长路径长度。
空间复杂度主要和如何记录路径有非常打的关系,广度优先搜索的实现可以使用递归函数也可以使用普通的循环。用递归函数的方法可以不单独开辟数组记录路径,通过处理递归回溯就可以得到路径,此时路径实际上是存储在程序堆栈里,若地图较大很容易造成内存溢出。
而使用普通的循环实现BFS,那么就需要将所有路径记录在开辟的数组中,虽然这样会占用更多内存空间,但路径是存储在内存的数据段中,可以存储的空间更大。而且存储的way数组是可以优化的,比如上面实现的样例,index=0属于第0层,index=1到index=3属于第1层,index=4到index=5属于第2层,第1层路径的信息包含了第0层,第2层路径的信息包含了第1层。那么实际上我再达到第n层的时候,第n-2层的路径就可以丢弃了。这样只保留2层的路径信息,会节省很多空间。
4.3. 时间复杂度
最差情形下,BFS必须寻找所有到可能节点的所有路径,因此其时间复杂度为 O(|V| + |E|),其中 |V| 是节点的数目,而 |E| 是图中边的数目。
4.4. 完全性
广度优先搜索算法具有完全性。这意指无论图形的种类如何,只要目标存在,则BFS一定会找到。然而,若目标不存在,且图为无限大,则BFS将不收敛(不会结束)。PS:无限大的图存在吗?
4.5. 最佳解
若所有边的长度相等,广度优先搜索算法是最佳解——亦即它找到的第一个解,距离根节点的边数目一定最少;但对一般的图来说,BFS并不一定回传最佳解。这是因为当图形为加权图(亦即各边长度不同)时,BFS仍然回传从根节点开始,经过边数目最少的解;而这个解距离根节点的距离不一定最短。这个问题可以使用考虑各边权值,BFS的改良算法成本一致搜寻法(en:uniform-cost search)来解决。然而,若非加权图形,则所有边的长度相等,BFS就能找到最近的最佳解。
4.6. 应用场景——平面网格中的路径搜索
BFS 可用来解决平面网格地图中的路径搜索,例如电脑游戏(例如即时策略游戏)中找寻路径的问题。在这个应用中,使用平面网格来代替图形,而一个格子即是图中的一个节点。所有节点都与它的邻居(上、下、左、右、左上、右上、左下、右下)相接。在平明网格中搜索路径,尤其是场景较大的,使用BFS将比DFS更快得到结果。
5. 其他内容
5.1. BoxHelper
可以在样例中看到,当鼠标选中某一对象时,会显示该对象的立方体外轮廓,这就用到了BoxHelper。
BoxHelper以图形方式显示对象周围的世界轴对齐边界框。实际的边界框用Box3处理,这只是一个用于调试的可视帮助器。当它的创建对象被转换时,它可以使用BoxHelper.update方法自动调整大小。请注意,对象必须具有Geometry或BufferGeometry才能工作,因此它不适用于Sprites。
样例:
var sphere = new THREE.SphereGeometry();
var object = new THREE.Mesh( sphere, new THREE.MeshBasicMaterial( 0xff0000 ) );
var box = new THREE.BoxHelper( object, 0xffff00 );
scene.add( box );
也可以创建BoxHelper后,通过setFromObject(object:Object3D):BoxHelper,方法来计算object的边界。
var box = new THREE.BoxHelper();
box.setFromObject(object);
5.2. raycaster
要在3D场景中使用鼠标拾取/选择对象,那么就需要用到Three的Raycaster, 该类利用光线投射的原理来检测与对象相交。
举例:
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
function onMouseMove( event )
{
// calculate mouse position in normalized device coordinates
// (-1 to +1) for both components
mouse.x = ( event.clientX / window.innerWidth ) * 2 - 1;
mouse.y = - ( event.clientY / window.innerHeight ) * 2 + 1;
}
function render() {
// update the picking ray with the camera and mouse position
raycaster.setFromCamera( mouse, camera );
// calculate objects intersecting the picking ray
var intersects = raycaster.intersectObjects( scene.children );
for ( var i = 0; i < intersects.length; i++ ) {
intersects[ i ].object.material.color.set( 0xff0000 );
}
renderer.render( scene, camera );
}
window.addEventListener( 'mousemove', onMouseMove, false );
window.requestAnimationFrame(render);
其中最重要的,就是要准确的计算出鼠标相对canvas范围内的坐标,在这个例子中,假设了canvas的范围是全屏,因此可以使用onMouseMove中的方法将直接得到的鼠标坐标转换到canvas范围内的(-1,1)范围内
1) 获取画布坐标
需要准确的得到鼠标和canvas的相对关系,首先需要准确的得到canvas的相对坐标:
var canvas = document.getElementById("canvas");
console.log(canvas.offsetTop, canvas.offsetLeft, canvas.offsetWidth, canvas.offsetHeight);
使用上面的方法可以准确的得到canvas对象在当前页面的相对坐标,但是我们在做网站的时候,经常使用嵌套页面,例如利用ajax技术使一个网页嵌套在另一个网页当中,这是这样只是获得了canvas在嵌套的网页中的相对坐标,那么要获得在全局网页中的坐标,就需要用到下面的函数:
function getOffset (e){
var t=e.offsetTop;
var l=e.offsetLeft;
while(e=e.offsetParent){
t+=e.offsetTop;
l+=e.offsetLeft;
}
return({l, t});
}
2) 归一化转换
得到canvas在全局网页中的相对坐标后,就可以将鼠标的坐标归一化了。
function getMousePosition( left,top,width,height, x, y ) {
return [ ( x - left ) / width, ( y - top ) / height ];
}
其中x和y是鼠标的全局坐标,就是event.clientX和event.clientY,而left,top就是通过getOffset函数返回的相对坐标。
3) 其他
getMousePosition返回的是数组,而mouse的坐标是Vector2,可以使用Vector2的fromArray来填充mouse的坐标。
ThreeJS框架支持处理触摸时间,因此在处理mouse事件的同事处理下touch事件是比较理想的兼容了移动端的操作。