使用Three.js绘制一个虚拟城市
这篇文章解释了如何使用代码来编写一座3D立体“城市”。这个代码是由@ mrdoob最新发布的演示Demo。我发现这个演示的算法很优雅,是一个简单而有效的解决方案,所以我发了一个帖子解释它。
关于算法的一些评论
在我们将关注焦点置于问题的细节之前,把握下问题的整体和全局是很有帮助的。这个3D虚拟城市所使用的算法是完全由程序所生成的,这意味着整个城市 是动态建立,而不参考任何模板。这个算法相当优雅,且不超过100行javascript代码。这个算法的原理是怎么样的呢?简而言之,每一个建筑是一个 立方体,他们得到随机的大小和位置。足够简单吗?听起来好像不切实际,但事实就是这样的,当你从城市底部往上看时就会发现这个秘密。
从性能的角度来看,所有的建筑都合并成一个单一的几何形状,用一个单一的材料。这是做法是非常有效的,因为没有着色器切换和绘图调用命令。
为了提高真实感,通过模拟自然光使用了vertexColor
小把戏。在这个城市,在街道级别里你可以看到来自其他建筑物的阴影,所以建筑物的底部比顶部暗,我们可以采用vertexColor
来重现这样的效果。我们采取建筑物的底部顶点,并使其比顶部更暗。
让我们开始吧
我们将逐步解释那100行代码:(1)生成建筑的基础几何形状 ;(2)在城市的合适位置放置建筑物;(3)使用vertexColor技巧模拟环境光和阴影;(4)合并所有的建筑物,这样整个城市可以在一次性绘制。不多说,让我们开始吧!
生成建筑的基础几何形状
我们首先需要建立构建城市建筑的基础几何形状,它会重复使用多次,然后构建起整个城市。所以我们建立了一个简单的CubeGeometry
对象。
var geometry = new THREE.CubeGeometry( 1, 1, 1 );
我们将参考点设置在立方体的底部,而不是它的中心,以便我们进行平移操作。
geometry.applyMatrix( new THREE.Matrix4().makeTranslation( 0, 0.5, 0 ) );
然后我们去掉立方体的底面,这是一个优化小技巧。因为建筑物的底面是不可见的,因为它始终是在地面上。所以它是无用的,我们将其删除。
geometry.faces.splice( 3, 1 );
现在我们修复顶面的UV映射,我们将它们设置为单一的坐标(0,0),这样屋顶将和地板颜色相同,且建筑物的各面纹理共用,这样使得我们可以可以在单一的绘图过程中完成绘制。这也是优化绘制的小技巧。
geometry.faceVertexUvs[0][2][0].set( 0, 0 );
geometry.faceVertexUvs[0][2][1].set( 0, 0 );
geometry.faceVertexUvs[0][2][2].set( 0, 0 );
geometry.faceVertexUvs[0][2][3].set( 0, 0 );
好了,现在我们得到了一个单体建筑的几何形状,让我们绘制更多的建筑物,让它看起来更像一个城市!
在城市的合适位置放置建筑物
嗯……说实话,我们可以把他们放在任何地方。全部是随机。但是要小心,因为建筑和建筑之间会发生碰撞。但不管怎样,我们先把建筑放在随机位置。
buildingMesh.position.x = Math.floor( Math.random() * 200 - 100 ) * 10;
buildingMesh.position.z = Math.floor( Math.random() * 200 - 100 ) * 10;
然后我们在Y方向做一个随机的旋转:
buildingMesh.rotation.y = Math.random()*Math.PI*2;
然后,我们通过设置mesh.scale
属性来改变建筑的大小。首先是如何建筑的宽度和深度。
buildingMesh.scale.x = Math.random()*Math.random()*Math.random()*Math.random() * 50 + 10;
buildingMesh.scale.z = buildingMesh.scale.x
然后是建筑物的高度:
buildingMesh.scale.y = (Math.random() * Math.random() * Math.random() * buildingMesh.scale.x) * 8 + 8;
我们设置好了建筑物的位置/旋转/缩放等属性。现在让我们设置它的颜色,以及如何使用它来模拟阴影。
使用vertexColor技巧模拟环境光和阴影
在建筑丛生的大城市,建筑的底部往往比顶端更暗。这是因为太阳光线照射到建筑物顶部比底部更容易,而且在建筑物底部往往由来自其它建筑物的阴影,这 是在图形编程中称之为环境光遮蔽(Ambient Occlusion)。使用ThreeJs,使得我们可以很轻易分配一种给定颜色给一个顶点,这最终将改变表面的最终颜色。我们要去利用这个特性来模拟建 筑物的底部的阴影。首先我们定义的向光面和背光面的基本色。
var light = new THREE.Color( 0xffffff )
var shadow = new THREE.Color( 0x303050 )
这些设置对于每个建筑来说都是基本的常数。现在我们需要根据这个常数来为每个建筑得到一些随机和特殊的颜色。
var value = 1 - Math.random() * Math.random();
var baseColor = new THREE.Color().setRGB( value + Math.random() * 0.1, value, value + Math.random() * 0.1 );
现在我们需要给每个面的每个顶点指定 vertexColor
的属性值。如果这个面是顶面,那么就使用该建筑的baseColor
。如果是侧面,那么使用baseColor
乘上light
作为上方顶点的颜色,使用baseColor
乘上shaddow
作为下方顶点的颜色来模拟环境光遮蔽的效果。
// 以baseColor作为参考设置上方顶点和下方顶点的颜色
var topColor = baseColor.clone().multiply( light );
var bottomColor = baseColor.clone().multiply( shadow );
// 每个面的每个顶点指定vertexColor的属性值
var geometry = buildingMesh.geometry;
for ( var j = 0, jl = geometry.faces.length; j < jl; j ++ ) {
if ( j === 2 ) {
// 如果这个面是顶面
geometry.faces[ j ].vertexColors = [ baseColor, baseColor, baseColor, baseColor ];
} else {
//如果这个面是侧面
geometry.faces[ j ].vertexColors = [ topColor, bottomColor, bottomColor, topColor ];
}
}
合并所有的建筑物
为了构建我们的城市,我们需要合并20000个建筑物在一起,为每个建筑物应用之前描述的过程。我们已经看到,降低绘图命令调用可以获取很好的性能。在这里,所有建筑物共用相同的材料,我们可以采用一个单一的几何物体来合并这些建筑。
var cityGeometry= new THREE.Geometry();
for( var i = 0; i < 20000; i ++ ){
// 为每个建筑设置位置、旋转、大小和颜色
// ...
// 合并所有建筑为单一的cityGeometry,可以有力的提升性能
THREE.GeometryUtils.merge( cityGeometry, buildingMesh );
}
现在,我们得到了整个城市的一个大几何形状,让我们采用它来构建Mesh对象。
// build the mesh
var material = new THREE.MeshLambertMaterial({
map : texture,
vertexColors : THREE.VertexColors
});
var mesh = new THREE.Mesh(cityGeometry, material );
这个Mesh对象就是我们想要构建的城市。还差一步,我们还需要添加纹理。
用程序生成纹理对象
在这里,我们要为每个建筑物的侧面生成纹理,以添加建筑的真实感。它采用交替的窗户和楼层进行。每个窗户通过不同的噪声模拟每个房间的光线明暗变化。
首先,你建一个Canvas画布,不需要很大,32×64就够了。
var canvas = document.createElement( ‘canvas‘ );
canvas.width = 32;
canvas.height = 64;
var context = canvas.getContext( ‘2d‘ );
将它绘制成白色
context.fillStyle = ‘#ffffff‘;
context.fillRect( 0, 0, 32, 64 );
现在,我们需要在这个白色的表面进行绘制,,一排窗户,一排地板,如此循环。事实上,我们只需要绘制窗户就好了。要绘制窗户,我们添加一些随机明暗变化来模拟每个窗户的灯光变化。
for( var y = 2; y < 64; y += 2 ){
for( var x = 0; x < 32; x += 2 ){
var value = Math.floor( Math.random() * 64 );
context.fillStyle = ‘rgb(‘ + [value, value, value].join( ‘,‘ ) + ‘)‘;
context.fillRect( x, y, 2, 1 );
}
}
现在我们已经有纹理了 32* 64 ,我们需要增加它的分辨率。首先,让我们创建一个更大的画布,1024*512。
var canvas2 = document.createElement( ‘canvas‘ );
canvas2.width = 512;
canvas2.height = 1024;
var context = canvas2.getContext( ‘2d‘ );
关掉默认的平滑处理:
context.imageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
context.mozImageSmoothingEnabled = false;
复制小画布的纹理到大的画布里去:
context.drawImage( canvas, 0, 0, canvas2.width, canvas2.height );
剩下的要做的就是根据这个大画布来创建真正的THREE.Texture
对象,
var texture = new THREE.Texture( generateTexture() );
texture.anisotropy = renderer.getMaxAnisotropy();
texture.needsUpdate = true;
这就是最后一步了!现在,你知道如何绘制一个虚拟的3D城市了,爽!下面是完整的代码。
// build the base geometry for each building
var geometry = new THREE.CubeGeometry( 1, 1, 1 );
// translate the geometry to place the pivot point at the bottom instead of the center
geometry.applyMatrix( new THREE.Matrix4().makeTranslation( 0, 0.5, 0 ) );
// get rid of the bottom face - it is never seen
geometry.faces.splice( 3, 1 );
geometry.faceVertexUvs[0].splice( 3, 1 );
// change UVs for the top face
// - it is the roof so it wont use the same texture as the side of the building
// - set the UVs to the single coordinate 0,0. so the roof will be the same color
// as a floor row.
geometry.faceVertexUvs[0][2][0].set( 0, 0 );
geometry.faceVertexUvs[0][2][1].set( 0, 0 );
geometry.faceVertexUvs[0][2][2].set( 0, 0 );
geometry.faceVertexUvs[0][2][3].set( 0, 0 );
// buildMesh
var buildingMesh= new THREE.Mesh( geometry );
// base colors for vertexColors. light is for vertices at the top, shaddow is for the ones at the bottom
var light = new THREE.Color( 0xffffff )
var shadow = new THREE.Color( 0x303050 )
var cityGeometry= new THREE.Geometry();
for( var i = 0; i < 20000; i ++ ){
// put a random position
buildingMesh.position.x = Math.floor( Math.random() * 200 - 100 ) * 10;
buildingMesh.position.z = Math.floor( Math.random() * 200 - 100 ) * 10;
// put a random rotation
buildingMesh.rotation.y = Math.random()*Math.PI*2;
// put a random scale
buildingMesh.scale.x = Math.random() * Math.random() * Math.random() * Math.random() * 50 + 10;
buildingMesh.scale.y = (Math.random() * Math.random() * Math.random() * buildingMesh.scale.x) * 8 + 8;
buildingMesh.scale.z = buildingMesh.scale.x
// establish the base color for the buildingMesh
var value = 1 - Math.random() * Math.random();
var baseColor = new THREE.Color().setRGB( value + Math.random() * 0.1, value, value + Math.random() * 0.1 );
// set topColor/bottom vertexColors as adjustement of baseColor
var topColor = baseColor.clone().multiply( light );
var bottomColor = baseColor.clone().multiply( shadow );
// set .vertexColors for each face
var geometry = buildingMesh.geometry;
for ( var j = 0, jl = geometry.faces.length; j < jl; j ++ ) {
if ( j === 2 ) {
// set face.vertexColors on root face
geometry.faces[ j ].vertexColors = [ baseColor, baseColor, baseColor, baseColor ];
} else {
// set face.vertexColors on sides faces
geometry.faces[ j ].vertexColors = [ topColor, bottomColor, bottomColor, topColor ];
}
}
// merge it with cityGeometry - very important for performance
THREE.GeometryUtils.merge( cityGeometry, buildingMesh );
}
// generate the texture
var texture = new THREE.Texture( generateTexture() );
texture.anisotropy = renderer.getMaxAnisotropy();
texture.needsUpdate = true;
// build the mesh
var material = new THREE.MeshLambertMaterial({
map : texture,
vertexColors : THREE.VertexColors
});
var cityMesh = new THREE.Mesh(cityGeometry, material );
function generateTexture() {
// build a small canvas 32x64 and paint it in white
var canvas = document.createElement( ‘canvas‘ );
canvas.width = 32;
canvas.height = 64;
var context = canvas.getContext( ‘2d‘ );
// plain it in white
context.fillStyle = ‘#ffffff‘;
context.fillRect( 0, 0, 32, 64 );
// draw the window rows - with a small noise to simulate light variations in each room
for( var y = 2; y < 64; y += 2 ){
for( var x = 0; x < 32; x += 2 ){
var value = Math.floor( Math.random() * 64 );
context.fillStyle = ‘rgb(‘ + [value, value, value].join( ‘,‘ ) + ‘)‘;
context.fillRect( x, y, 2, 1 );
}
}
// build a bigger canvas and copy the small one in it
// This is a trick to upscale the texture without filtering
var canvas2 = document.createElement( ‘canvas‘ );
canvas2.width = 512;
canvas2.height = 1024;
var context = canvas2.getContext( ‘2d‘ );
// disable smoothing
context.imageSmoothingEnabled = false;
context.webkitImageSmoothingEnabled = false;
context.mozImageSmoothingEnabled = false;
// then draw the image
context.drawImage( canvas, 0, 0, canvas2.width, canvas2.height );