现在随着城市的发展,越来越多的智慧摄像头,都被互联网公司布到城市的各个角落,举一个例子,一个大楼上上下下都被布置了智能摄像头,用于监控火势,人员进出,工装工牌佩戴等监控,这时候为了美化项目,大公司都会将城市的区域作为对象,进行3d可视化交互,接下来的内容,就是基于以上元素,开发的一款城市数据可视化的demo,包含楼宇特效,飞线,特定视角,动画等交互,
用到的技术栈 vite + typescript + threejs
搜索关键词:city
压缩包包含的内容
下面是具体代码
export function loadGltf(url: string) {
return new Promise
模型上有一些咱们用不到的模型,进行删除,还有一些用的到的模型,但是名称不友好,所以进行整理
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是场景的构建函数,主要创建了场景,镜头,控制器,灯光等基础信息,并且监听控制器变化时修改灯光位置
在使用第三方模型的时候,总有一些不尽人意的地方,比如模型加载后,模型中心并不在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))
}
没有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>[]) => 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));
})
收集后的顶点信息:
这部分的工作只不过判断鼠标是否移动的部分不一样而已。
有了飞线具体经过的点位时候,要将这些点位细化,这时就要讲飞线的大致原理了,两点确定一条线段,获取线段上的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之后要在render中调用
render() {
this.controls.update()
this.renderer.render(this.scene, this.camera);
this._Fly && this._Fly.upDate()
}
可配置参数有尺寸,透明度,颜色等
var color1 = params.starColor; //轨迹线颜色 青色
var color2 = params.endColor; //黄色
var color = color1.lerp(color2, i / newPoints2.length)
colorArr.push(color.r, color.g, color.b);
这里是引用渐变色的位置,需要再调整一下
将模型绘制出线稿,并添加到原有模型上,这里用到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)边缘几何体(EdgesGeometry)# 边缘几何体(EdgesGeometry);通过边缘几何体的信息创建 # 线段(LineSegments)线段(LineSegments) # 线段(LineSegments);并将创建出来的线段添加到原有模型中,因为我们的线段不需要单独处理,
/**
*
* @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信息,
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);
...
}
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(() => {
// 动画结束的回调,可以展示建筑信息或其他操作
})
}
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)
...
}