第9章:多摄像机、多视图、多角度摄影

1、学会在场景中使用多个视图

2、学会多个相机的使用

1、常被学员问的一个问题

有一个问题,是我们经常会遇到的,就是在一个窗口中,有几个子窗口,在子窗口中显示场景不同角度的动画。例如小地图,就是一个极好的例子。我们看看下面一幅图:

这幅图中红色划圈的部分,就是通过不同视图的渲染来做到的,它综合使用了透视相机和正投影相机。

上图被红线圈住的地方,就是正投影相机绘制出来的。因为它的大小永远不会变化。看一下左上角的血条吧,试想一下,如果它不断的变化大小和角度,那么这将是一个多么低端的游戏啊。

前面的基础课程,我们已经讲解了怎么来设置视图,但是还不深入,这一节课,我们将深入这一部分。深入的目的,是为了以后大家遇到同类的问题,能够有思路,有想法,而不至于不会做。

如果和前面的课程相比,有一些跳跃,或者觉得基础知识讲得还不够多,那是我们我们 想尽快得让大家深入了解WebGL的相关体系,如果讲得太快,敬请大家原谅。如果跟不上老师的节奏,建议购买100多集视频课程,那里有更深入的讲解。

2、本课最终效果

我们首先来看一下我们这节课要完成的效果,如下图所示:

上图中,我们有3个画布,分别放在页面的上方,中部偏右和下部偏左。3个画布里面分别显示了一个场景的不同区域。在图形学中,我们通常将这种最终的显示画布叫做视口(viewport)。关于视口的详细信息,这里先不累述了,在需要深入的时候,我们会告诉你它的相应知识。

为了第一时间看到效果,点击这里,看看效果吧。

1、有阴影出现

注意,demo中的多面体的各个面的颜色是不一样的,有一点渐变。多面体底部是有一些阴影的,这里没有显示出来。移动一下鼠标,你能够看到阴影。

2、怎么推断时候有光源

从多面体表面呈现的光照情况,可以断定有一个灯光。多面体上没有聚光的效果,所以应该是没有聚光灯的。也不是每个地方都有一样的光照,所以环境光的可能性不大。最后,推断应该是方向光。哈哈,我也希望,你能这样去思考问题哦。

Ok,分析差不多就这些了。

为了第一时间看到效果,你还可以先将【】拖到chrome浏览器中,看看效果。

同时,你也可以右键源代码,看到本例的源代码。后面的讲解,你最好打开一份源代码,边看后面的解释边对照一下源代码。源码面前了无秘密,这是我们的哲学思维。

3、构造界面

首先,我们要根据上面的描述,使用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,界面大功告成,我们接着往下面看。

4、代码解析:初始化函数

我们将代码分段来解释,这样更容易理解,首先映入你眼前的是初始化函数,主要完成程序的初始化工作。代码如下:

// 判断浏览器是否支持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对象有什么用,我们来仔细分析一下。

5、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:只显示视口中某一部分的高度。

这几个参数,我们用一个图来解释一下:

对照上面的图,在重新看一下解释,你会更明白。记住,学习的过程中,我们不求快,只求明白。其中绿色部分表示最终要显示到浏览器上的部分。

6、View类源码分析

View类中主要在设置相机,灯光,添加多面体等工作。我们对View类中的函数一个一个来讲解,请你打开一份代码,对照学习。不然一定会有听天书的感觉。

请大家预览一下这个类的代码,在它里面定义了1个render函数和一些初始化代码,下面,我们分别来解释一下这些代码。

1、初始化相机

首先是相机的初始化,我们这里使用透视投影相机,代码及注释如下:

// 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;

	......
}

2、详解setViewOffset

ok,上面的代码除了camera.setViewOffset()这个函数之外,都没有什么难点,我们来看看这个函数。这个函数是让视图的某一部分显示在屏幕上。

setViewOffset( fullWidth, fullHeight, x, y, width, height )

各个参数的解释如下:

fullWidth:整个视图(口)的宽度,也可以理解为相机的宽度。

fullHeight:整个视图(口)的高度,也可以理解为相机的高度。

x:视图的x轴偏移位置,及要显示的部分相对于左上角的偏移。

y:视图的y轴偏移位置

width:子视图的宽度,只有这个宽度才被显示

height:子视图的高度,只有这个高度才被显示

这些参数,也可以用这幅图来表示

setViewOffset表示在渲染的时候,我们只显示浅绿色部分。只有这一部分才会被映射到最终的屏幕上(canvas这个div)中去。

3、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 );

};

其中每一次渲染都会根据鼠标移动相机的位置,这样就能够通过移动鼠标看到场景的各个方面。

7、场景和光照

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 );

8、定义阴影面

仔细观察本例的运行效果,你可以在球的底部看到一块阴影,如下图红色圆圈的地方:

那么这里的阴影是怎么实现的呢?

为了让大家更容易学习,我们这里的阴影不是通过光照生成的,而是简单的使用了一个像阴影的贴图,贴到一个平面上生成的。同时,也可以让大家学习一种新方法。

这里要实现阴影的效果,我们需要一个像阴影的纹理,一种带有这种纹理的材质和一个带有这种材质的平面。好了,这就是我们需要的全部,它们是紧密相连的,但最终只有一个目的,生成像阴影一样的平面,然后放到多面体(球)的下方。

我们这里选择在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个平面阴影,并将其放置在了场景中。有了阴影,还差像球体一样的二十面体,下面,我们就来添加二十面体。

5、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面体的不同面都能够显示不同的颜色了。

5、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度之间即可,如下图。

5、4、创建让平面和线框同时显示的物体

在本例中,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)。带有线框的效果如下:

5、4、5、声明渲染器

接下来的代码是声明渲染器,代码如下:

renderer = new THREE.WebGLRenderer( { antialias: true } );
renderer.setPixelRatio( window.devicePixelRatio );
renderer.setSize( fullWidth, fullHeight );

这里将渲染器设置为反锯齿,这样渲染的图像更清晰,质量越高,当然消耗了更多CPU资源。然后通过setSize函数设置了渲染器的高度和宽度正好等于container的高度和宽度。

最后将渲染器的结果加到container中,这样渲染的结果才能显示出来。

5、4、7.总渲染函数

代码的最后又一个总渲染函数,将3个View的结果渲染出来。代码如下所示:

function animate() {

	for ( var i = 0; i < views.length; ++i ) {

		views[ i ].render();

	}

	requestAnimationFrame( animate );

}

这一段代码一看就懂,就不解释了。

5、4、8.让场景响应鼠标的移动

在上一节的渲染函数中,我们使用了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,一切都讲完了,运行一下代码,看看您的杰作吧。

5、4、9.结语

人生有时候就是一个过了河的小卒子,想过去重新来一次,但是却回不去。想前进,但是又缺乏勇气。

但有些时候,唯有技术和代码让我们感觉到存在。

各位同学,要有创业心,要有自己的想法。好好练习技术吧。

感谢你阅读本课,非常感谢,晚安。

于北京晚

给WebGL中文网团队的女程序员"小果妹妹"发一个鸡腿吧,微信扫一扫赞赏,感谢。

亲爱的读者,如果你觉得WebGL中文网的课程不错,您可以购买《WebGL中文网视频课程》 课程支持我们哦,购买后记得给我们好评哦!我们强烈建议您不要在iphone上的网易云课堂软件中购买,这样苹果会收取31%左右的服务费,虽然这是明码标价,我们也表示认可和理解,具体选择权在您自己了。

感谢大家的支持,下面是课程的截图之一

[1楼] life** 2019-04-27 11:38

怎么没评论

[2楼] life** 2019-04-27 11:38

怎么没评论

[3楼] smil** 2019-06-27 13:25

图片咋都404了呢

[4楼] 你是猪** 2019-12-05 17:46

图片都看不了 很吐血啊

[5楼] KIPK** 2020-07-12 19:24

后面干货部分的代码没一个能正常运行的,真的服

提问或评论

登陆后才可留言或提问哦:) 登陆 | 注册 登陆后请返回本课提问
用户名
密   码
验证码