Hope is a dangerous thing, but I have it.


【实验分析】使用Three.js构建简单城市模型

写这个东西是因为最近要交图形学的大作业了,元旦赶了几天最后写了出来。其实很多都是参考了@mrdoob之前的工作了,所以更多的来说算是一个学习过程。他们具体的代码可以参见这里,演示的效果可以参见这里。我们项目的地址在这里。设计的大体思路我都介绍了一下,有些地方没有贴代码,可以根据github的源码来看。另外使用three.js一定要注意api的更新!官方文档是你的好朋友。

使用手册

程序功能

本程序是基于WebGL和three.js开发的一款模拟城市的程序。程序提供的功能如下:

  1. 动态生成城市的简单模型,包括街道、楼房和人行道。
  2. 完成图形的渲染,并使得城市具有合理的光照和阴影效果。
  3. 用户可以通过鼠标和键盘,改变视角,包括视角的上下左右移动和旋转。

使用方法

打开程序:双击文件夹中的index.html文件即可打开程序。
   城市漫游:本程序以鼠标和键盘为主要的交互方式,用户可以通过点击鼠标或者按键盘来改变看城市的视角。

  1. 鼠标移动:视角旋转;
  2. 鼠标左键:视角向前平移;
  3. 鼠标右键:视角向后平移;
  4. W键:视角向前平移;
  5. S键:视角向后平移;
  6. A键:视角向左平移;
  7. D键:视角向右平移;
  8. R键:视角向上平移;
  9. F键:视角向下平移。

设计思路

本程序目的是完成一个模拟城市的简单程序。程序上大致分为三个部分:three.js基本对象的创建、城市的构建和交互的完成。

three.js基本对象(main.js)

要想通过three.js来渲染想要的场景,就像需要准备一些基本的对象,包括renderer、camera、scene和light。具体的设计如下:

  1. renderer:渲染器,设置为窗口大小,渲染场景。
  2. camera:使用透视相机,调整合适的高度。
  3. scene:创建出城市的场景,加入其他元素或背景。
  4. light:同时使用多种光源,保证城市明亮度的同时,呈现出楼房的阴影效果。

城市的构建(city.js)

程序将城市分为四个部分:地面、街道、人行道和楼房,分别完成每个部分的创建(包括形状和纹理),然后将它们添加到城市的object中,最后将这个object添加到scene中。
   因为城市的人行道和楼房都是立方体的,而且存在多次重复构建的过程,所以首先构造了一个城市的基本单元buildingMesh。通过设定buildingMesh的不同形状、大小和纹理,可以得到不同样式的立方体。以buildingMesh为基础,通过对buildingMesh进行修改得到不同样式的人行道和楼房。
   在城市构建的过程中,加入了社区的概念。即一个社区有一个方形的人行道(也可以理解为社区的地面),社区内有多个楼房,数量可以通过设定楼房密度进行修改。社区和社区之间有街道隔开,但是对于街道,程序中并没有生成某个3D对象,而是将空出来的地面直接作为街道。
   城市的构建每一次的结果都不同。虽然社区的数量和大小是固定的,但是社区内楼房的大小、位置和颜色是通过随机数得到的,所以每一次运行生成的城市都不同。

用户的交互(control.js)

用户可以通过鼠标和键盘进行交互,从而改变看城市的视角的位置。这方面是通过JavaScript的事件流完成的,通过对键盘和鼠标添加监听,对用户的不同操作计算camera的不同的操作,并修改。

实现过程

three.js基本对象

在创建three.js的基本对象的过程中,创建了renderer、camera、scene和light,并调整到合适的大小和位置。除此之外,还做了以下的一些实现:

  1. 城市的光照和阴影。在城市的光源的创建过程中,使用了three.js自带的两个光源:AmbientLight和SpotLight。通过AmbientLight均匀的照亮场景中的所有物体,来增加城市的整体亮度。但是由于AmbientLight没有方向,所以不能实现阴影的效果,所以额外添加了一个SpotLight,通过点光源来实现楼房的阴影效果,并将所有物体的castShadow和receiveShadow设置为true,同时更改renderer和spotLight的设置,使得阴影效果可行。为了显示点光源的位置,程序中额外添加了一个红色的球体,位置与点光源位置相同,可以表示太阳的位置。
// 添加点光源
let spotLight=new THREE.SpotLight(0xffffcc);
spotLight.position.set(200, 150, 0);
spotLight.castShadow=true;
scene.add(spotLight);
// 添加球体
let geometry = new THREE.SphereGeometry( 5, 32, 32 );
let material = new THREE.MeshBasicMaterial( {color: 0xCC0000} );
let sphere = new THREE.Mesh( geometry, material );
sphere.position.set(200, 150, 0);
scene.add(sphere);
// 打开renderer开关
renderer.shadowMap.enabled=true;
renderer.shadowMap.type=THREE.PCFSoftShadowMap;
  1. 天空盒。因为最终没有找到合适的天空盒,所以这个并没有在最终的程序中体现出来,但是对此我做了实现。通过texture的load函数可以将图片载入到纹理中,并将纹理设置为scene的background。但是在读取图片过程中会有错,所以需要使用Nginx搭建一个本地服务器在服务器上运行这段代码,或者使用node搭建一个http服务器。最终因为没有合适的天空盒,没有设置scene的背景,直接使用renderer.clearcolor设置天空的颜色。
  2. 城市的雾化效果。在创建scene的过程中,通过添加three.js的FogExp2对象,并设置初始参数,实现城市的雾化效果,使得远方的城市有朦胧的效果。
scene.fog= new THREE.FogExp2( 0xd0e0f0, 0.004 );

城市的创建

城市的构建是分块进行的。首先构造出城市的基本单元cube,然后使用cube搭建出楼房和人行道,并将地面、楼房和人行道加入到城市中,完成城市的构造。

基本单元buildingMesh的创建

城市的基本单元buildingMesh是在一个立方体cube的基础上得到的。所以在得到buildingMesh之前,要先生成一个cube。
   为了之后坐标系计算的方便,先通过更改cube的变换矩阵将中心移到了cube的底面中心。

cube.applyMatrix(new THREE.Matrix4().makeTranslation(0,0.5,0));   //将参考点设置在立方体的底部

cube的底面是不可见的,所以为了减少之后计算过程中的工作,删掉了cube的底面。底面是由两个三角形组成的,并不是只是一个方形,所以删除要针对两个三角形分别进行。通过调试输出cube各个三角形的坐标,y轴为0的两个三角形就是底面的三角形,得到对应的序号然后删除。

cube.faces.splice(6, 2);   //去掉立方体的底面
cube.faceVertexUvs[0].splice(6, 2);

在进一步的给楼房添加纹理时,是将纹理添加到cube的每一个面上绘制楼房的窗户的时候,楼房的顶面不要有这个纹理,所以删掉了楼房顶面的纹理。也是通过调试得到顶面顶点对应的坐标然后修改它的UV值。

 cube.faceVertexUvs[0][4][0].set(0, 0);
 cube.faceVertexUvs[0][4][1].set(0, 0);
 cube.faceVertexUvs[0][4][2].set(0, 0);

 cube.faceVertexUvs[0][5][0].set(0, 0);
 cube.faceVertexUvs[0][5][1].set(0, 0);
 cube.faceVertexUvs[0][5][2].set(0, 0);

通过以上操作就得到了cube,然后以它为对象创建城市的基本单元buildingMesh。

let buildingMesh=new THREE.Mesh(cube);

地面的创建

城市的地面时通过three.js的PlaneGeometry形状和MeshLambertMaterial纹理构建出来的,其实只要设定好大小为单位大小和颜色,然后通过scale缩放成为包含所有社区的大小就行。但是在缩放的过程中,ground如果对z轴进行缩放会有一些问题,所以我是将它按照y轴进行缩放,然后旋转得到理想的地面。并将地面加入城市。

let geometry=new THREE.PlaneGeometry(1,1,1);
let material=new THREE.MeshLambertMaterial({color:0x222222});
let ground=new THREE.Mesh(geometry,material);
ground.rotation.x = (-90 * Math.PI) / 180;
ground.scale.x=blockNumX*blockSizeX;
ground.scale.y=blockNumZ*blockSizeZ;
ground.receiveShadow=true;

人行道的创建

一个社区里面有一块方形的人行道。人行道以Geometry为形状,然后通过for循环,针对每一个社区,依次修改buildingMesh的x和z轴的坐标。然后对buildingMesh的x、y、z进行缩放,使得buildingMesh可以满足人行道的大小。将每个楼房加入人行道中,最后将人行道加入城市。

楼房的创建

每一个社区里面有多个楼房。每一栋楼房都是在buildingMesh的基础上创造出来的。通过设定buildingMesh的坐标x和z。然后随机初始化楼房的大小,使得楼房的大小符合一定的分布,并使得x轴和z轴长度在5~15之间,高度通过x轴长度得到。
   随机初始化每个面的颜色,并且使得颜色也符合二次分布。针对每个面分别绘制颜色。由于新版的three.js是通过三角形构成面,所以添加颜色时也是向三个顶点添加颜色。
   将每栋楼房加入城市楼房群中,通过修改material添加城市楼房群的纹理。
   最后将城市楼房群加入城市。

楼房的纹理

楼房和人行道、地面的不同在于它是有窗户的,所以需要为每个楼房生成具有窗户的纹理图像。
   先在一个较小的6432的canvas中间进行绘制,针对偶数行,每一行画上窗户,通过随机数使得窗户的颜色在一定范围内变化。
   然后创建一个大的1024512的canvas,将原先的小画布上的内容重绘到大的画布上。

致谢

无比感谢丁丁在这个过程中给予的重要的无私的帮助!