threejs 開發可視化數字城市效果
現在隨着城市的發展,越來越多的智慧攝像頭,都被互聯網公司布到城市的各個角落,舉一個例子,一個大樓上上下下都被佈置了智能攝像頭,用於監控火勢,人員進出,工裝工牌佩戴等監控,這時候爲了美化項目,大公司都會將城市的區域作爲對象,進行 3d 可視化交互,接下來的內容,就是基於以上元素,開發的一款城市數據可視化的 demo,包含樓宇特效,飛線,特定視角,動畫等交互,希望可以給大家帶來一 neinei 的幫助,話不多說,開整
用到的技術棧 vite + typescript + threejs
白模
下載白模
模型下載網站 [上海模型](City- Shanghai-Sandboxie - Download Free 3D model by Michael Zhang (@beyond.zht) [3eab443] (sketchfab.com))
搜索關鍵詞:city
壓縮包包含的內容
模型加載
模型下載的是 gltf 格式,所以要用到 threejs 提供的 # GLTFLoader,下面是具體代碼
export function loadGltf(url: string) {
return new Promise<Object>((resolve, reject) => {
gltfLoader.load(url, function (gltf) {
console.log('gltf',gltf)
resolve(gltf)
});
})
}
處理模型
模型上有一些咱們用不到的模型,進行刪除,還有一些用的到的模型,但是名稱不友好,所以進行整理
loadGltf('./models/scene.gltf').then((gltf: any) => {
const group = gltf.scene
const scale = 10
group.scale.set(scale, scale, scale)
// 刪除多餘模型
const mesh1 = group.getObjectByName('Text_test-base_0')
if (mesh1 && mesh1.parent) mesh1.parent.remove(mesh1)
const mesh2 = group.getObjectByName('Text_text_0')
if (mesh2 && mesh2.parent) mesh2.parent.remove(mesh2)
// 重命名模型
// 環球金融中心
const hqjrzx = group.getObjectByName('02-huanqiujinrongzhongxin_huanqiujinrongzhongxin_0')
if (hqjrzx) hqjrzx.name = 'hqjrzx'
// 上海中心
const shzx = group.getObjectByName('01-shanghaizhongxindasha_shanghaizhongxindasha_0')
if (shzx) shzx.name = 'shzx'
// 金茂大廈
const jmds = group.getObjectByName('03-jinmaodasha_jjinmaodasha_0')
if (jmds) jmds.name = 'jmds'
// 東方明珠塔
const dfmzt = group.getObjectByName('04-dongfangmingzhu_dongfangmingzhu_0')
if (dfmzt) dfmzt.name = 'dfmzt'
T.scene.add(group)
T.toSceneCenter(group)
T.ray(group.children, (meshList) => {
console.log('meshList', meshList);
})
T.animate()
})
T 是場景的構建函數,具體可以查看 gitee 中的文件,這裏就不贅述了,主要創建了場景,鏡頭,控制器,燈光等基礎信息,並且監聽控制器變化時修改燈光位置
在使用第三方模型的時候,總有一些不盡人意的地方,比如模型加載後,模型中心並不在 3d 世界的中心位置,所以就需要調整一下模型整體的位置,toSceneCenter
方法是自定義的一個讓模型居中的方法,通過# Box3 獲取到模型的包圍盒,獲取到模型的中心點座標信息,再取反,就會得到模型中心點在 3d 世界的位置信息
// 獲取包圍盒
getBoxInfo(mesh) {
const box3 = new THREE.Box3()
box3.expandByObject(mesh)
const size = new THREE.Vector3()
const center = new THREE.Vector3()
// 獲取包圍盒的中心點和尺寸
box3.getCenter(center)
box3.getSize(size)
return {
size, center
}
}
toSceneCenter(mesh) {
const { center, size } = this.getBoxInfo(mesh)
// 將Y軸置爲0
mesh.position.copy(center.negate().setY(0))
}
階段代碼
以上代碼地址 城市加載白模 v2.0.1
飛線
收集飛線的點
沒有 3d 設計師的支持,所有的數據都來自於模型,所以利用現有條件,收集飛線經過的點位,原理就是使用到的鼠標射線,點擊模型上的某個位置並記錄下來,提供給後期使用
衆所周知,click 的調用過程是忽略 mousedown 的,mouseup 時候就會調用,如果單純的想要改變視角,鼠標抬起時候也會調用 click 事件,所以要加一個鼠標是否移動的判斷,利用控制器監聽 start 和 end 時的鏡頭位置變化來區分鼠標是否移動
控制器部分代碼:
this.controls.addEventListener('start', () => {
this.controlsStartPos.copy(this.camera.position)
})
this.controls.addEventListener('end', () => {
this.controlsMoveFlag = this.controlsStartPos.distanceToSquared(this.camera.position) === 0
})
控制器開始變化的時候記錄 camera 位置,跟結束時的 camera 的位置相減,如果爲 0,則表示鼠標沒晃動,單純的點擊,如果不爲 0,說明鏡頭位置變化了,這時,鼠標的 click 回調將不會調用
射線部分代碼:
ray(children: THREE.Object3D[], callback: (mesh: THREE.Intersection<THREE.Object3D<THREE.Event>>[]) => void) {
let mouse = new THREE.Vector2(); //鼠標位置
var raycaster = new THREE.Raycaster();
window.addEventListener("click", (event) => {
mouse.x = (event.clientX / document.body.offsetWidth) * 2 - 1;
mouse.y = -(event.clientY / document.body.offsetHeight) * 2 + 1;
raycaster.setFromCamera(mouse, this.camera);
const rallyist = raycaster.intersectObjects(children);
if (this.controlsMoveFlag) {
callback && callback(rallyist)
}
});
}
射線的回調:
let arr = []
T.ray(group.children, (meshList) => {
console.log('meshList', meshList);
arr.push(...meshList[0].point.toArray())
console.log(JSON.stringify(arr));
})
收集後的頂點信息:
這部分的工作和之前寫 # threejs 打造 world.ipanda.com 同款 3D 首頁時候收集熊貓基地的點位是一樣的,只不過判斷鼠標是否移動的部分不一樣而已。
細化頂點
有了飛線具體經過的點位時候,要將這些點位細化,這時就要講飛線的大致原理了,兩點確定一條線段,獲取線段上的 100 個點,每條飛線佔用 20 個點位,每個點位創建一個着色器,用於繪製飛線的組成部分,當更新時候,飛線的首個點向下一個點前進,一次往後 20 個點都往前前進一次,循環往復一直到飛線的最後一個組成部分到達線段的最後一個點,飛線佔用的點位數量決定飛線的長度,將線段分爲多少個頂點,決定飛線的疏密程度,像圖中這樣的疏密度,就是單個線段的點位分少了,這個可以優化的,vector3.distanceTo(vector3)
即可判斷兩個線段的長度,通過不同的長度,決定細化線段的點,當然,線段的頂點信息越多,對 gpu 的消耗越大
flyLineData.forEach((data: number[]) => {
const points: THREE.Vector2[] = []
for (let i = 0; i < data.length / 3; i++) {
const x = data[i * 3]
const z = data[i * 3 + 2]
const point = new THREE.Vector2(x, z)
points.push(point)
}
const curve = new THREE.SplineCurve(points);
// 此處決定飛線每個點的疏密程度,數值越大,對gpu的壓力越大
const curvePoints = curve.getPoints(100);
const flyPoints = curvePoints.map((curveP: THREE.Vector2) => new THREE.Vector3(curveP.x, 0, curveP.y))
// const l = points.length - 1
const flyGroup = T._Fly.setFly({
index: Math.random() > 0.5 ? 50 : 20,
num: 20,
points: flyPoints,
spaced: 50, // 要將曲線劃分爲的分段數。默認是 5
starColor: new THREE.Color(Math.random() * 0xffffff),
endColor: new THREE.Color(Math.random() * 0xffffff),
size: 0.5
})
flyLineGroup.add(flyGroup)
})
setFly 參數
interface SetFly {
index: number, // 截取起點
num: number, // 截取長度 // 要小於length
points: Vector3[],
spaced: number // 要將曲線劃分爲的分段數。默認是 5
starColor: Color,
endColor: Color,
size: number
}
endColor 和 starColor 目前不好用,做不出漸變,不知道是不是長度不夠,暫時先放放
flyLine
創建 flyLine 做成了一個類,開箱即用,也可以加入自己的想法,調整內容,
創建 flyLine 之後要在 render 中調用
render() {
this.controls.update()
this.renderer.render(this.scene, this.camera);
this._Fly && this._Fly.upDate()
}
new Fly()
方法詳見飛線 fly.ts
可配置參數有尺寸,透明度,顏色等
var color1 = params.starColor; //軌跡線顏色 青色
var color2 = params.endColor; //黃色
var color = color1.lerp(color2, i / newPoints2.length)
colorArr.push(color.r, color.g, color.b);
這裏是引用漸變色的位置,需要再調整一下
階段代碼
以上代碼地址 城市飛線 v2.0.2
線稿
將模型繪製出線稿,並添加到原有模型上,這裏用到LineBasicMaterial
基礎線條材質,和MeshLambertMaterial
基礎網格材質,調節顏色和不透明度。
材質代碼:
// 建築材質
export const otherBuildingMaterial = (color: THREE.Color, opacity = 1) => {
return new THREE.MeshLambertMaterial({
color,
transparent: true,
opacity
});
}
// 建築線條材質
export const otherBuildingLineMaterial = (color: THREE.Color, opacity = 1) => {
return new THREE.LineBasicMaterial(
{
color,
depthTest: true,
transparent: true,
opacity
}
)
}
以下代碼是之前對模型改造時寫的對模型重命名的方法,現在我們來改造一下
// 重命名模型
// 環球金融中心
const hqjrzx = group.getObjectByName('02-huanqiujinrongzhongxin_huanqiujinrongzhongxin_0')
if (hqjrzx) {
hqjrzx.name = 'hqjrzx'
changeModelMaterial(hqjrzx, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
}
// 上海中心
const shzx = group.getObjectByName('01-shanghaizhongxindasha_shanghaizhongxindasha_0')
if (shzx) {
shzx.name = 'shzx'
changeModelMaterial(shzx, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
}
// 金茂大廈
const jmds = group.getObjectByName('03-jinmaodasha_jjinmaodasha_0')
if (jmds) {
jmds.name = 'jmds'
changeModelMaterial(jmds, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
}
// 東方明珠塔
const dfmzt = group.getObjectByName('04-dongfangmingzhu_dongfangmingzhu_0')
if (dfmzt) {
dfmzt.name = 'dfmzt'
changeModelMaterial(dfmzt, otherBuildingMaterial(buildColor, buildOpacity), otherBuildingLineMaterial(buildLineColor, buildLineOpacity),buildLineDeg)
}
T.scene.add(group)
T.toSceneCenter(group)
group.traverse((mesh: any) => {
mesh as THREE.Mesh
if (mesh.isMesh && (mesh.name.indexOf('Shanghai') !== -1 || mesh.name.indexOf('Object') !== -1)) {
if (mesh.name.indexOf('Floor') !== -1) {
mesh.material = floorMaterial
} else if (mesh.name.indexOf('River') !== -1) {
} else {
changeModelMaterial(mesh, otherBuildingMaterial(otherBuildColor,0.8), otherBuildingLineMaterial(otherBuildLineColor,0.4),buildLineDeg)
}
}
})
changeModelMaterial
這個方法就是創建模型相對應的線條的方法,獲取到模型的geometry
,這裏存着模型所有的頂點信息,索引和法向量,以此創建一個# 邊緣幾何體(EdgesGeometry);通過邊緣幾何體的信息創建 # 線段(LineSegments);並將創建出來的線段添加到原有模型中,因爲我們的線段不需要單獨處理,所以這裏寫的方法會比之前在# threejs 渲染高級感可視化渦輪模型 一文中寫的簡化的很多,如果需要單獨對線段處理的同學,可以採用這篇文章裏的 changeModelMaterial
方法
/**
*
* @param object 模型
* @param lineGroup 線組
* @param meshMaterial 模型材質
* @param lineMaterial 線材質
*/
export const changeModelMaterial = (mesh: THREE.Mesh, meshMaterial: THREE.MeshBasicMaterial, lineMaterial: THREE.LineBasicMaterial, deg = 1): any => {
if (mesh.isMesh) {
if (meshMaterial) mesh.material = meshMaterial
// 以模型頂點信息創建線條
const line = getLine(mesh, deg, lineMaterial)
const name = mesh.name + '_line'
line.name = name
mesh.add(line)
}
}
// 通過模型創建線條
export const getLine = (object: THREE.Mesh, thresholdAngle = 1, lineMaterial: THREE.LineBasicMaterial): THREE.LineSegments => {
// 創建線條,參數爲 幾何體模型,相鄰面的法線之間的角度,
var edges = new THREE.EdgesGeometry(object.geometry, thresholdAngle);
var line = new THREE.LineSegments(edges);
if (lineMaterial) line.material = lineMaterial
return line;
}
關於顏色
對於我這種野生前端開發,沒有 UI 和 UE 的支持,只能在網上找案例,那麼就需要圖片中的顏色,這裏不得不提到一個工具色輪、調色盤產生器 | Adobe Color
色彩
這裏可以根據一個顏色,調出互補色、相似色、單色等色彩信息
取色
這個工具也可以根據一張圖片,提取出主題色,包含主色、輔助色等信息
階段代碼
以上代碼地址 城市線稿輪廓
預設鏡頭位置
預埋點位
預埋的點位座標信息獲取和飛線點位獲取一樣的方法,標記採用的是# CSS2DRenderer,將創建的 element 節點渲染到 3d 世界,3drender 和 2drender 不在同一個圖層內,所以需要新建一個 dom 節點,專門存放 css2d 的 dom 信息,
<div id="css2dRender"></div>
加載 css2drender
createScene 文件
+renderCss2D: CSS2DRenderer
createRenderer(){
...
this.renderCss2D = new CSS2DRenderer({ element: this.css2dDom });
this.renderCss2D.setSize(this.width, this.height);
...
}
render(){
...
this.renderCss2D.render(this.scene, this.camera);
...
}
根據數據創建 dom 節點
export interface CameraPosInfo {
pos: number[], // 預設攝像機位置信息
target: number[], // 控制器目標位置
name: string, // 預埋標記點或其他信息
tagPos?: number[], // 預埋標記點的位置信息
}
接下來就是要根據信息創建節點,遍歷這些信息,並創建節點,這裏有一個點需要提一下,2d 圖層和 3d 圖層的關係,這裏要是不介紹清楚,後面沒法進行
從圖中可以看出,2d 圖層始終保持在 3d 圖層的上層,然而我們在創建控制器的時候,第二個參數使用的是 3d 的圖層,this.controls = new OrbitControls(this.camera, this.renderer.domElement)
,因爲這一層被覆蓋了,所以控制器失效了。
有兩種解決方案,第一種是 new OrbitControls
時,將第二個參數改爲this.renderCss2D.domElement
,還有一種方式,也就是本文采用的方式,將 2d 圖層的 css 屬性改變一下,忽略這個圖層的任何事件。
#css2dRender {
/* 一定要加這個屬性,不然2D內容點擊沒效果 */
pointer-events: none;
}
由於pointer-events
屬性是可以繼承的,2d 圖層內所有的元素都不響應事件,所以要將咱們創建的建築 tag 的樣式改一下
.build_tag {
/* 一定要加這個屬性,不然2D內容點擊沒效果 */
pointer-events: all;
}
第一次寫這方面的代碼的時候,也是頭疼了好久,慢慢摸索才摸索出來的。
// 創建建築標記
function createTag() {
const buildTagGroup = new THREE.Group()
T.scene.add(buildTagGroup)
presetsCameraPos.forEach((cameraPos: CameraPosInfo, i: number) => {
if (cameraPos.tagPos) {
// 渲染2d文字
const element = document.createElement('li');
// 將信息存入dom節點中,如果是react或者vue寫的,不用這麼存,直接存data或者state
element.setAttribute('data-cameraPosInfo', JSON.stringify(cameraPos))
element.classList.add('build_tag')
element.innerText = `${i + 1}`
// 將初始化好的dom節點渲染成CSS2DObject,並在scene場景中渲染
const tag = new CSS2DObject(element);
const tagPos = new THREE.Vector3().fromArray(cameraPos.tagPos)
tag.position.copy(tagPos)
buildTagGroup.add(tag)
}
})
}
鏡頭動畫
這裏通過事件代理,點擊到相應的建築 tag,從 dom 節點上獲取到data-cameraPosInfo
屬性,然後通過 tween 動畫處理器修改控制器的 taget 和鏡頭的 position。事件代理是 js 基礎內容,這裏就不贅述了
if (css2dDom) {
css2dDom.addEventListener('click', function (e) {
if (e.target) {
if(e.target.nodeName=== 'LI') {
console.dir(e);
const cameraPosInfo = e.target.getAttribute('data-cameraPosInfo')
if (cameraPosInfo) {
const {pos,target} = JSON.parse(cameraPosInfo)
T.controls.target.set(...target)
T.handleCameraPos(pos)
}
}
}
});
}
handleCameraPos
的代碼
handleCameraPos(end: number[]) {
// 結束時候相機位置
const endV3 = new THREE.Vector3().fromArray(end)
// 目前相機到目標位置的距離,根據不同的位置判斷運動的時間長度,從而保證速度不變
const length = this.camera.position.distanceTo(endV3)
// 如果位置相同,不運行動畫
if(length===0) return
new this._TWEEN.Tween(this.camera.position)
.to(endV3, Math.sqrt(length) * 400)
.start()
// .onUpdate((value) => {
// console.log(value)
// })
.onComplete(() => {
// 動畫結束的回調,可以展示建築信息或其他操作
})
}
階段代碼
以上代碼地址 建築鏡頭動畫 v2.0.4
場景背景渲染
scene 的場景不僅支持顏色和 texture 紋理,還支持 canvas,上面的黑色背景太單調了,所以利用 canvas 繪製一個圓漸變填充到 scene.background
createScene(){
...
const drawingCanvas = document.createElement('canvas');
const context = drawingCanvas.getContext('2d');
if (context) {
// 設置canvas的尺寸
drawingCanvas.width = this.width;
drawingCanvas.height = this.height;
// 創建漸變
const gradient = context.createRadialGradient(this.width / 2, this.height, 0, this.width/2, this.height/2, Math.max(this.width, this.height));
// 爲漸變添加顏色
gradient.addColorStop(0, '#0b171f');
gradient.addColorStop(0.6, '#000000');
// 使用漸變填充矩形
context.fillStyle = gradient;
context.fillRect(0, 0, drawingCanvas.width, drawingCanvas.height);
this.scene.background = new THREE.CanvasTexture(drawingCanvas)
...
}
其他風格
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1o6HrXTxQTprP8LKMLEcLA