多摄像机多视图多角度摄影
常被学员问的一个问题
有一个问题,是我们经常会遇到的,就是在一个窗口中,有几个子窗口,在子窗口中显示场景不同角度的动画。例如小地图,就是一个极好的例子。我们看看下面一幅图:
这幅图中红色划圈的部分,就是通过不同视图的渲染来做到的,它综合使用了透视相机和正投影相机。
上图被红线圈住的地方,就是正投影相机绘制出来的。因为它的大小永远不会变化。看一下左上角的血条吧,试想一下,如果它不断的变化大小和角度,那么这将是一个多么低端的游戏啊。
前面的基础课程,我们已经讲解了怎么来设置视图,但是还不深入,这一节课,我们将深入这一部分。深入的目的,是为了以后大家遇到同类的问题,能够有思路,有想法,而不至于不会做。
如果和前面的课程相比,有一些跳跃,或者觉得基础知识讲得还不够多,那是我们我们 想尽快得让大家深入了解WebGL的相关体系,如果讲得太快,敬请大家原谅。如果跟不上老师的节奏,建议购买100多集视频课程,那里有更深入的讲解。
本课最终效果
我们首先来看一下我们这节课要完成的效果,如下图所示:
上图中,我们有3个画布,分别放在页面的上方,中部偏右和下部偏左。3个画布里面分别显示了一个场景的不同区域。在图形学中,我们通常将这种最终的显示画布叫做视口(viewport)。关于视口的详细信息,这里先不累述了,在需要深入的时候,我们会告诉你它的相应知识。
为了第一时间看到效果,点击这里,看看效果吧。 1、有阴影出现 注意,demo中的多面体的各个面的颜色是不一样的,有一点渐变。多面体底部是有一些阴影的,这里没有显示出来。移动一下鼠标,你能够看到阴影。
2、怎么推断时候有光源 从多面体表面呈现的光照情况,可以断定有一个灯光。多面体上没有聚光的效果,所以应该是没有聚光灯的。也不是每个地方都有一样的光照,所以环境光的可能性不大。最后,推断应该是方向光。哈哈,我也希望,你能这样去思考问题哦。
Ok,分析差不多就这些了。
为了第一时间看到效果,你还可以先将【】拖到chrome浏览器中,看看效果。
同时,你也可以右键源代码,看到本例的源代码。后面的讲解,你最好打开一份源代码,边看后面的解释边对照一下源代码。源码面前了无秘密,这是我们的哲学思维。
构造界面
首先,我们要根据上面的描述,使用div+css来构造出界面,div结构如下所示,你可以在源码中找到:
<div id="container">
<canvas id="canvas1"></canvas>
<canvas id="canvas2"></canvas>
<canvas id="canvas3"></canvas>
</div>
这是一个很简单的div结构,container里面嵌套了3个容器。这3个容器分别代表下面的三个画布。如下图所示:
这几个id对应的Css样式如下所示:
#canvas1, #canvas2, #canvas3 {
position: relative;
display: block;
border: 1px solid red ; // 边框红色
}
#canvas1 {
width: 300px;
height: 200px;
}
#canvas2 {
width: 400px;
height: 100px;
left: 150px;
}
#canvas3 {
width: 200px;
height: 300px;
left: 75px;
}
可以看出以下几点:
1、canvas1、canvas2、canvas2的边框显示是红色,并且它们都是使用的绝对定位。
2、canvas1的宽度是300px,高度是200px。
他的都很简单,这里就不累述了。由于这里的每个div并没有清除浮动,所以每个div是占一行的。Ok,界面大功告成,我们接着往下面看。
代码解析:初始化函数
我们将代码分段来解释,这样更容易理解,首先映入你眼前的是初始化函数,主要完成程序的初始化工作。代码如下:
// 判断浏览器是否支持WebGL,如果不支持会在div中打印一串不支持信息出来
if ( WEBGL.isWebGLAvailable() === false ) {
// 不支持,这直接打印getWebGLErrorMessage返回的不支持的文字信息
document.body.appendChild( WEBGL.getWebGLErrorMessage() );
}
var views = [];
init();
animate();
我们接下来深入到init函数中看一下她美丽的身材:
function init() {
var canvas1 = document.getElementById( 'canvas1' );
var canvas2 = document.getElementById( 'canvas2' );
var canvas3 = document.getElementById( 'canvas3' );
var fullWidth = 550;
var fullHeight = 600;
views.push( new View( canvas1, fullWidth, fullHeight, 0, 0, canvas1.clientWidth, canvas1.clientHeight ) );
views.push( new View( canvas2, fullWidth, fullHeight, 150, 200, canvas2.clientWidth, canvas2.clientHeight ) );
views.push( new View( canvas3, fullWidth, fullHeight, 75, 300, canvas3.clientWidth, canvas3.clientHeight ) );
// 此处省略了部分代码,完整代码请下载查看。
......
}
init函数顾名思义是初始化函数,在这个函数里,我们做了如下几件事情:
1、使用document.getElementById函数得到了3个div容器,他们将作为画布使用。
2、定义了一个宽度和高度的变量,如下:
var fullWidth = 550;
var fullHeight = 600;
这里的宽度和高度不是任意定义的,它刚好是包含了3个div的大小,如下图所示:
我们用550,600稍后用来设置摄像机的宽度和高度,这样相机的纵横比就出来了。
3、然后,构造了一些View对象,并放入到了views数组中。View对象有什么用,我们来仔细分析一下。
View(视图)类
View类将相机,视图、灯光、等场景封装到了一个类里面,这是为了重用的需要。它并不对应WebGL中的任何术语,只是为了代码整洁,而定义的一个类而已,这里大家,一定不要被View这个概念误导了。
因为本例的3个不同场景大同小异,只是摄像机的位置不太相同,所以很多细节可以重用。
我们先来熟悉一下View这个类,View的构造函数如下所示:
function View( canvas, fullWidth, fullHeight, viewX, viewY, viewWidth, viewHeight )
它的参数的含义如下所述:
canvas:表示要在上面显示内容的div的id名字。如canvas1、canvas2。
fullWidth:视口的宽度
fullHeight:视口的高度
viewX:我们可以选择只显示视口的某一部分,viewX表示从视口x坐标的哪里开始显示。
viewY:如上,viewY表示从视口Y坐标的哪里开始显示。
viewWidth:只显示视口中某一部分的宽度
viewHeight:只显示视口中某一部分的高度。
这几个参数,我们用一个图来解释一下:
对照上面的图,在重新看一下解释,你会更明白。记住,学习的过程中,我们不求快,只求明白。其中绿色部分表示最终要显示到浏览器上的部分.
View类源码分析
View类中主要在设置相机,灯光,添加多面体等工作。我们对View类中的函数一个一个来讲解,请你打开一份代码,对照学习。不然一定会有听天书的感觉。
请大家预览一下这个类的代码,在它里面定义了1个render函数和一些初始化代码,下面,我们分别来解释一下这些代码。
初始化相机
首先是相机的初始化,我们这里使用透视投影相机,代码及注释如下
// View类的几个参数已经在上面讲解了,如果不会,请回到上面看一下。
function View( canvas, fullWidth, fullHeight, viewX, viewY, viewWidth, viewHeight ) {
// 这里主要是对于一些retina屏幕设置的,大多数屏幕devicePixelRatio=1。
// 这里你可以不用关心,如果需要深入学习,可以看一下这篇文章:https://www.zhangxinxu.com/wordpress/2012/08/window-devicepixelratio/
canvas.width = viewWidth * window.devicePixelRatio;
canvas.height = viewHeight * window.devicePixelRatio;
// getContext() 方法返回一个用于在画布上绘图的环境。
var context = canvas.getContext( '2d' );
// 初始化一个透视相机,
// 第一个参数设置相机的透视角为20度;
// 第二个参数设置宽高比,宽高比为canvas的宽度除以高度,这是和视口同样的宽高比,能够使投影到视口中的景象不变形。
// 第三、第四个参数
能够看到从相机前1个单位到10000个单位的距离。单位你可以自己在整个系统中取,你可以以米为单位,也可以以厘米为单位,只要你愿意。
var camera = new THREE.PerspectiveCamera( 20, viewWidth / viewHeight, 1, 10000 );
// 这是设置相机的有效显示部分,表示只显示相机里面的一个部分。对照上一节的图,就是显示上图的浅绿色部分
camera.setViewOffset( fullWidth, fullHeight, viewX, viewY, viewWidth, viewHeight );
// 将相机的位置放到(0,0,1800)的位置,x,y坐标不变,代表默认为0
camera.position.z = 1800;
......
}
详解setViewOffset
ok,上面的代码除了camera.setViewOffset()这个函数之外,都没有什么难点,我们来看看这个函数。这个函数是让视图的某一部分显示在屏幕上。
setViewOffset( fullWidth, fullHeight, x, y, width, height )
各个参数的解释如下:
fullWidth:整个视图(口)的宽度,也可以理解为相机的宽度。
fullHeight:整个视图(口)的高度,也可以理解为相机的高度。
x:视图的x轴偏移位置,及要显示的部分相对于左上角的偏移。
y:视图的y轴偏移位置
width:子视图的宽度,只有这个宽度才被显示
height:子视图的高度,只有这个高度才被显示
这些参数,也可以用这幅图来表示
setViewOffset表示在渲染的时候,我们只显示浅绿色部分。只有这一部分才会被映射到最终的屏幕上(canvas这个div)中去。
View类的Render渲染函数
View类中有一个渲染函数,它的目的是将结果渲染到canvas(div)中。代码如下所示:
this.render = function () {
// 根据鼠标来为相机设置不同的位置,从而得到不同视角观看场景的机会
camera.position.x += ( mouseX - camera.position.x ) * 0.05;
camera.position.y += ( - mouseY - camera.position.y ) * 0.05;
// 相机永远指向场景的原点位置,场景的位置默认在世界坐标的原点,而且一般我们不会移动scene的位置
camera.lookAt( scene.position );
// 设置渲染器的宽度和高度
renderer.setViewport( 0, 0, viewWidth, viewHeight );
// 根据场景和相机渲染整个画面
renderer.render( scene, camera );
// 将渲染器的渲染结果domElement绘制到context中,也就是canvas画布中。
context.drawImage( renderer.domElement, 0, 0 );
};
其中每一次渲染都会根据鼠标移动相机的位置,这样就能够通过移动鼠标看到场景的各个方面。
场景和光照
View类介绍完后,我们来看一下其他代码。在代码中定义了场景和光照,我们定义了一个白色的方向光,从(0,0,1)点射向(0,0,0)点。代码如下所示:
scene = new THREE.Scene();
light = new THREE.DirectionalLight( 0xffffff );
light.position.set( 0, 0, 1 ).normalize();
scene.add( light );
定义阴影面
仔细观察本例的运行效果,你可以在球的底部看到一块阴影,如下图红色圆圈的地方
那么这里的阴影是怎么实现的呢?
为了让大家更容易学习,我们这里的阴影不是通过光照生成的,而是简单的使用了一个像阴影的贴图,贴到一个平面上生成的。同时,也可以让大家学习一种新方法。
这里要实现阴影的效果,我们需要一个像阴影的纹理,一种带有这种纹理的材质和一个带有这种材质的平面。好了,这就是我们需要的全部,它们是紧密相连的,但最终只有一个目的,生成像阴影一样的平面,然后放到多面体(球)的下方。
我们这里选择在canvas画布上绘制来生成阴影,在html5中有一个画布canvas,在上面我们可以画出想要的图形,当然由于都在浏览器中,这个图形肯定是适合于three.js的。画阴影的代码如下所示:
// 创建画布
var canvas = document.createElement( 'canvas' );
// 设置画布大小宽128,高128
canvas.width = 128;
canvas.height = 128;
// 得到画布的可画类context
var context = canvas.getContext( '2d' );
// 创建一个放射性渐变,渐变从画布的中心开始,到以canvas.width/2为半径的圆结束。如果不明白可以参考:http://www.w3school.com.cn/htmldom/met_canvasrenderingcontext2d_createradialgradient.asp
var gradient = context.createRadialGradient( canvas.width / 2, canvas.height / 2, 0, canvas.width / 2, canvas.height / 2, canvas.width / 2 );
// 在离画布中心canvas.width*0.1的位置添加一种颜色,
gradient.addColorStop( 0.1, 'rgba(210,210,210,1)' );
// 在画布渐变的最后位置添加一种颜色,
gradient.addColorStop( 1, 'rgba(255,255,255,1)' );
// 填充方式就是刚才创建的渐变填充
context.fillStyle = gradient;
// 实际的在画布上绘制渐变。
context.fillRect( 0, 0, canvas.width, canvas.height );
上面绘制画布的代码的效果如下:
画布做好了,然后我们将画布转化为纹理,代码如下:
var shadowTexture = new THREE.CanvasTexture( canvas );
注意CanvasTexture可以从canvas画布中创建一个纹理。然后由纹理定义材质,代码如下:
var shadowMaterial = new THREE.MeshBasicMaterial( { map: shadowTexture } );
这样我们就定义了平面使用的基本材质。Ok,材质定义好后,就把它赋给阴影平面吧,我们有三个球,所以有三个阴影,代码如下:
// 定义一个宽和高都是300的平面,平面内部没有出现多个网格。
var shadowGeo = new THREE.PlaneGeometry( 300, 300, 1, 1 );
// 构建一个Mesh网格,位置在(0,-250,0),即y轴下面250的距离。
mesh = new THREE.Mesh( shadowGeo, shadowMaterial );
mesh.position.y = - 250;
// 围绕x轴旋转-90度,这样竖着的平面就横着了。阴影是需要横着的。
mesh.rotation.x = - Math.PI / 2;
scene.add( mesh );
// 第二个平面阴影
mesh = new THREE.Mesh( shadowGeo, shadowMaterial );
mesh.position.x = - 400;
mesh.position.y = - 250;
mesh.rotation.x = - Math.PI / 2;
scene.add( mesh );
// 第三个平面阴影
mesh = new THREE.Mesh( shadowGeo, shadowMaterial );
mesh.position.x = 400;
mesh.position.y = - 250;
mesh.rotation.x = - Math.PI / 2;
scene.add( mesh );
这样就生成了3个平面阴影,并将其放置在了场景中。有了阴影,还差像球体一样的二十面体,下面,我们就来添加二十面体。
生成20面体,并给每个顶点赋予不同的颜色
看下面一幅图,可以发现20面体的每个面都不是纯色。显示在面上的颜色都是渐变色。这种渐变色是将三角形面的每一个顶点赋予不同的颜色值形成的。为什么只设置三角形三个顶点的颜色,三角形面上其他像素点的颜色就可以生成呢?这是因为三角形里上其他点的颜色可以通过3个顶点的颜色自动找到相似色,然后显示出来。
完成这个任务的代码如下所示,注释中解释得很清楚了,请认真分析
// 20面体的半径是200单位
var radius = 200;
// 生成3个20面体
var geometry1 = new THREE.IcosahedronBufferGeometry( radius, 1 );
// 从20面体中的顶点数组(geometry1.attributes.position)中获得顶点的个数,目的是为每个顶点设置一个颜色属性
var count = geometry1.attributes.position.count;
// 为几何体设置一个颜色属性,颜色属性的属性名必须是‘color’,这样Three.js才认识。
geometry1.addAttribute( 'color', new THREE.BufferAttribute( new Float32Array( count * 3 ), 3 ) );
// 由于场景中有3个20面体,所以这里通过clone函数复制3个。clone函数将生产一模一样的一个几何体。
var geometry2 = geometry1.clone();
var geometry3 = geometry1.clone();
// 这里声明一个临时的颜色值,待会用来作为中间结果。
var color = new THREE.Color();
// 获得3个几何体的位置属性数组
var positions1 = geometry1.attributes.position;
var positions2 = geometry2.attributes.position;
var positions3 = geometry3.attributes.position;
// 获得3个几何体的颜色属性数组
var colors1 = geometry1.attributes.color;
var colors2 = geometry2.attributes.color;
var colors3 = geometry3.attributes.color;
// 使用HSL颜色空间来设置颜色。下文将简单介绍这种颜色空间。
// 为每个顶点设置一种颜色
for ( var i = 0; i < count; i ++ ) {
color.setHSL( ( positions1.getY( i ) / radius + 1 ) / 2, 1.0, 0.5 );
colors1.setXYZ( i, color.r, color.g, color.b );
color.setHSL( 0, ( positions2.getY( i ) / radius + 1 ) / 2, 0.5 );
colors2.setXYZ( i, color.r, color.g, color.b );
color.setRGB( 1, 0.8 - ( positions3.getY( i ) / radius + 1 ) / 2, 0 );
colors3.setXYZ( i, color.r, color.g, color.b );
}
这样,20面体的不同面都能够显示不同的颜色了。
HSL颜色空间
上面的代码中出现了setHSL函数,这个函数是THREE.Color()对象用来设置颜色值的。我们打开源码看一下这个函数,这个函数被定义在Three.js的color.js中,代码如下:
function setHSL( h, s, l )
函数接受3个参数,分别HSL颜色模型中的3个分量。这3个分量到底是什么?我们从HSL模型谈起。
HSL模型是一种人性化的颜色模型,为什么这么说呢?我们来详细解释一下。
HSL使用了3个分量来描述色彩,与RGB使用的三色光(红绿蓝)不同,HSL色彩的表述方式是:H(hue)色相,S(saturation)饱和度,以及L(lightness)亮度。听起来一样复杂?稍后你就会发现,与“反人类”的RGB模型相比,HSL是多么的友好。
HSL的H(hue)分量,代表的是人眼所能感知的颜色范围,这些颜色分布在一个平面的色相环上,取值范围是0°到360°的圆心角,每个角度可以代表一种颜色。如下图,不同不同角度代表不同颜色
这个值叫做色相值,它的意义在于,我们可以在不改变光感的情况下,通过旋转色相环来改变颜色。在实际应用中,我们需要记住色相环上的六大主色,用作基本参照:360°/0°红、60°黄、120°绿、180°青、240°蓝、300°洋红,它们在色相环上按照60°圆心角的间隔排列,如上图。
HSL的S(saturation)分量,指的是色彩的饱和度,它用0%至100%的值描述了相同色相、明度下色彩纯度的变化。数值越大,颜色中的灰色越少,颜色越鲜艳,呈现一种从理性(灰度)到感性(纯色)的变化,如下图:
S的值越大,颜色越纯净。
HSL的L(lightness)分量,指的是色彩的明度,作用是控制色彩的明暗变化。它同样使用了0%至100%的取值范围。数值越小,色彩越暗,越接近于黑色;数值越大,色彩越亮,越接近于白色。
对了HSL颜色模型有一定了解以后,我们不仅会想,这种颜色模型有什么好处呢? 其实答案已经很接近了:就是,我们可以容易的从这几个分量中,看出它是哪种颜色。如我们要调一种红色,只需要将H设置为330-360度,0到30度之间即可,如下图。
创建让平面和线框同时显示的物体
在本例中,20面体比较特殊,不但显示了表面,而且显示了表面周围的线框。这在three.js引擎中是不支持这种同时显示平面和线框的。
为了实现这个效果,我们可以创建2个不同材质的相同物体,来近似的模拟这种效果。
实现这个功能的代码如下:
var material = new THREE.MeshPhongMaterial( {
color: 0xffffff,
flatShading: true,
vertexColors: THREE.VertexColors,
shininess: 0
} );
var wireframeMaterial = new THREE.MeshBasicMaterial( { color: 0x000000, wireframe: true, transparent: true } );
var mesh = new THREE.Mesh( geometry1, material );
var wireframe = new THREE.Mesh( geometry1, wireframeMaterial );
mesh.add( wireframe );
mesh.position.x = - 400;
mesh.rotation.x = - 1.87;
scene.add( mesh );
这段代码用不同的材质,绘制了2个网格模型,这2个网格模型使用了同样的几何体对象(geometry)。带有线框的效果如下
声明渲染器
接下来的代码是声明渲染器,代码如下:
renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( fullWidth, fullHeight );
这里将渲染器设置为反锯齿,这样渲染的图像更清晰,质量越高,当然消耗了更多CPU资源。然后通过setSize函数设置了渲染器的高度和宽度正好等于container的高度和宽度。
最后将渲染器的结果加到container中,这样渲染的结果才能显示出来
总渲染函数
代码的最后又一个总渲染函数,将3个View的结果渲染出来。代码如下所示:
function animate() {
for ( var i = 0; i < views.length; ++i ) {
views[ i ].render();
}
requestAnimationFrame( animate );
}
这一段代码一看就懂,就不解释了。dddDDDdff
让场景响应鼠标的移动
在上一节的渲染函数中,我们使用了mouseX,mouseY这一对全局变量,来控制相机的位置,这里我们来实时的得到鼠标的位置(mouseX,mouseY),为了得到鼠标的位置,我们必须监听鼠标移动的事件,请对照整个实例的源代码来分析,局部的代码如下:
document.addEventListener('mousemove', onDocumentMouseMove, false );
这句话的意思是在网页的document中监听mousemove事件,当鼠标移动的时候,触发onDocumentMouseMove函数,这个函数的代码如下所示:
function onDocumentMouseMove( event ) {
mouseX = ( event.clientX - windowHalfX );
mouseY = ( event.clientY - windowHalfY );
}
这里对event.ClientX和event.ClientY进行了一些处理,event.ClientX和event.ClientY的原点是窗口左上角,通过减去窗口一半的距离。让mouseX和mouseY,当鼠标在窗口中心点的时候,其值为0,这样原点就好像移动到了窗口的中心位置。
Ok,一切都讲完了,运行一下代码,看看您的杰作吧。
结语
人生有时候就是一个过了河的小卒子,想过去重新来一次,但是却回不去。想前进,但是又缺乏勇气。
但有些时候,唯有技术和代码让我们感觉到存在。
各位同学,要有创业心,要有自己的想法。好好练习技术吧。
感谢你阅读本课,非常感谢,晚安。