這篇文章解釋了如何使用代碼來編寫一座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 );