初始化场景&准备工作
在vue3+threejs当中,初始化场景的代码基本上是一样的,可以参考前面几篇文章的初始化场景代码。在这里进行渲染3D地图还需要用到d3这个库,所以需要安装一下d3,直接npm i即可。
再从阿里云这里提供的全国各个省市的地图json数据下载一份自己需要展示的json数据。初始化的区别在于需要创建一个渲染器
- CSS3DRenderer用于通过CSS3的transform属性, 将层级的3D变换应用到DOM元素上。 如果你希望不借助基于canvas的渲染来在你的网站上应用3D变换,那么这一渲染器十分有趣。 同时,它也可以将DOM元素与WebGL的内容相结合
- CSS2DRenderer是CSS3DRenderer的简化版本,唯一支持的变换是位移
// 创建渲染器
const labelRenderer = new CSS2DRenderer();
const addRenderer = () => {
labelRenderer.domElement.style.position = 'absolute';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.pointerEvents = 'none';
labelRenderer.setSize(window.innerWidth, window.innerHeight);
document.getElementById('map').appendChild(labelRenderer.domElement);
};
创建材质
这里封装一个方法,用来处理不同块的数据。这是在json文件当中他会按照不同的省市进行区分不同的块。
Shape:使用路径以及可选的孔洞来定义一个二维形状平面
- 首先创建了一个多边形对象,之后循环data数组(这个data数组就对应着json里面的coordinates数组的下一层数组,这个等下调用这个方法的时候组装进来)
- 然后循环创建点。offsetXY是d3当中的一个方法,他的作用是将经纬度给转换成xy坐标,因为json和three里面的坐标系不是一样的所以需要转换一层
- 再进行判断,如果是第一个点就是起点,那么就用moveTo方法移动到这个点开始绘制,如果不是第一个点就用lineTo绘制一条线过去。这里使用了负的y值,因为Three.js的坐标系与地理坐标系的y轴方向相反
ExtrudeGeometry:挤压缓冲几何体(从一个形状路径中,挤压出一个BufferGeometry)他也是BufferGeometry的一个子类
- 上一步已经创建好了一个Shape形状了之后,需要再进一步创建一个几何体,使用到是这个ExtrudeGeometry(看名字我们也知道是要做什么了)
- 直接构造
- depth:float类型,挤出的形状的深度,默认值为1
- bevelEnabled:boolean类型,对挤出的形状应用是否斜角,默认值为true
MeshStandardMaterial:标准网格材质
- color:材质颜色
- emissive:材质的放射(光)颜色,基本上是不受其他光照影响的固有颜色。默认为黑色
- roughness:材质的粗糙程度。0.0表示平滑的镜面反射,1.0表示完全漫反射
- metalness:材质与金属的相似度。非金属材质,如木材或石材,使用0.0,金属使用1.0
- transparent:定义此材质是否透明。这对渲染有影响,因为透明对象需要特殊处理,并在非透明对象之后渲染。设置为true时,通过设置材质的opacity属性来控制材质透明的程度
- side:定义将要渲染哪一面 。正面,背面或两者。 默认为THREE.FrontSide。其他选项有THREE.BackSide 和 THREE.DoubleSide
最后通过Mesh将几何体和材质添加到一起进行返回
const offsetXY = d3.geoMercator();
/**
* 绘制每个市的区域
* @param data 坐标数据
* @param color 颜色
* @param depth 深度
* */
const createMesh = (data, color, depth) => {
const shape = new THREE.Shape();
data.forEach((item, idx) => {
const [x, y] = offsetXY(item);
if (idx === 0) shape.moveTo(x, -y);
else shape.lineTo(x, -y);
});
const extrudeSettings = {
depth,
bevelEnabled: false
};
const materialSettings = {
color: color,
emissive: 0x000000,
roughness: 0.45,
metalness: 0.8,
transparent: true,
side: THREE.DoubleSide
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const material = new THREE.MeshStandardMaterial(materialSettings);
return new THREE.Mesh(geometry, material);
};
渲染地图
在上一步创建材质的时候就把所需要渲染的材质都给创建好了,这一步只需要导入json,然后把json对应的data经纬度数据传给createMesh方法就大功告成了。接下来我们看一下渲染地图的方法
- 先取json文件里面第一个子对象的经纬度出来作为默认的中心点
- Object3D这是Three.js中大部分对象的基类,提供了一系列的属性和方法来对三维空间中的物体进行操纵,后续所有创建的mesh等等都加在这个里面
- 然后遍历json内容去找需要的data数据,这里需要通过MultiPolygon多重多边形 和 Polygon多边形,两者的区别在于找到对应的数据层级是不一样的,相差一层,可以对比一个内蒙古和其他省的数据。
- 最后找到那一层数据就调用createMesh创建材质的方法,得到材质之后加到Object3D对象里面,最后加到场景里面就完成了
/**
* @param data 完整的json数据
*/
const createMap = (data) => {
const map = new THREE.Object3D();
const center = data.features[0].properties.centroid;
// d3的方法表示将center作为xy坐标系的0,0点
offsetXY.center(center).translate([0, 0]);
data.features.forEach((feature) => {
const unit = new THREE.Object3D();
const {name, adcode} = feature.properties;
const {coordinates, type} = feature.geometry;
const depth = 1;
coordinates.forEach((coordinate) => {
if (type === 'MultiPolygon') coordinate.forEach((item) => fn(item));
if (type === 'Polygon') fn(coordinate);
function fn(coordinate) {
unit.name = name;
unit.adcode = adcode;
const mesh = createMesh(coordinate, '#63bbd0', depth);
unit.add(mesh);
}
});
map.add(unit);
});
scene.add(map);
};
结果这一步我们就可以直接查看效果了。但是由于最开始指定的是第一个数据的点为中心点也就是北京,渲染出来的并没有居中,并且最开始给设置的相机位置是(0,64,64)这样看起来也不对劲。
居中处理
在上一步scene.add(map)之前调用该方法。
- roration 先给map对象旋转-90度,让整个地图正面朝上
- 创建Box3对象,用setFromObject方法根据地图对象计算出其包围盒,再通过getCenter拿到中心点
- 因为最开始我们设置的中心点是(0,0,0)也就是重新设置中心点直接等于负center即可,后续如果设置的中心点不是(0,0,0)就这样计算一遍,这样整个地图也就居中展示了
const setCenter = (map) => {
map.rotation.x = -Math.PI / 2;
const box = new THREE.Box3().setFromObject(map);
const center = box.getCenter(new THREE.Vector3());
map.position.x = map.position.x - center.x;
map.position.z = map.position.z - center.z;
};
添加行政区边界线
在创建各个块元素材质的时候就已经可以拿到边界线的数据了,这里封装一个方法。
- 先把经纬度转成xy坐标然后存在point里面
- 创建BufferGeometry,可以直接通过setFromPoints将所有的点数据给塞进去,这样就创建了一个Geometry
- 再创建两个材质只要指定一下线的颜色,然后调整一下他们的z坐标,返回出去即可
- 最后在初始化地图的地方一起调用即可
const createLine = (data, depth) => {
const points = [];
data.forEach((item) => {
const [x, y] = offsetXY(item);
points.push(new THREE.Vector3(x, -y, 0));
});
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
// 地图上面的线
const upLineMaterial = new THREE.LineBasicMaterial({color: '#000000'});
// 地图下面的线
const downLineMaterial = new THREE.LineBasicMaterial({color: '#000000'});
const upLine = new THREE.Line(lineGeometry, upLineMaterial);
const downLine = new THREE.Line(lineGeometry, downLineMaterial);
downLine.position.z = -0.1;
upLine.position.z = depth + 0.1;
return [upLine, downLine];
};
// 调用这个:创建面、创建线,然后统统加到Object3D对象当中
const mesh = createMesh(coordinate, '#63bbd0', depth);
const line = createLine(coordinate, depth);
unit.add(mesh, ...line);
添加省市信息
还是一样的,在json文件当中我们可以拿到省市的经纬度数据和名称,在这里创建一个div通过CSS2DObject将div加到three当中,然后就是把经纬度坐标转换成xy坐标,其中y坐标是反的所以去一个反(在前面有提到过了),然后z也要+depth(地图的高度)这样lable标签也就完整的渲染到地图上了。
const createLabel = (name, point, depth) => {
const div = document.createElement('div');
div.style.color = '#000';
div.style.fontSize = '12px';
// 这个见仁见智哈,感觉加上整个都变模糊了
// div.style.textShadow = '1px 1px 2px #047cd6';
div.textContent = name;
const label = new CSS2DObject(div);
label.scale.set(0.01, 0.01, 0.01);
const [x, y] = offsetXY(point);
label.position.set(x, -y, depth);
return label;
};
添加纹理贴图
在实际开发过程当中,如果是要做那种山脉、地形的图我们就需要添加纹理到MeshStandardMaterial当中,上面只是简单的用颜色来控制展示,举一反三:上面的颜色值是固定的,定义一个获取随机颜色的方法替换掉固定的颜色值就是随机颜色的地图了。
这里还有一个小缺陷的,在这里加载纹理图片上来(图片可以随便来个),然后给纹理进行相对应的配置,直接给mesh加上,但是感觉纹理贴图贴在了边缘,没有贴到正面。这个也有点懵逼,不知道到底贴到了正面了没。正面看起来的效果倒也还行主要是
const mapTexture = new THREE.TextureLoader().load(mapTextureImage);
mapTexture.ratation = Math.PI;
mapTexture.wrapS = THREE.RepeatWrapping;
mapTexture.wrapT = THREE.RepeatWrapping;
mapTexture.repeat.set(1, 1);
mapTexture.needsUpdate = true;
const materialSettings = {
map: mapTexture,
bumpMap: mapTexture,
bumpScale: 0.01,
transparent: true,
opacity: 0.8,
side: THREE.DoubleSide
};
const material = new THREE.MeshStandardMaterial(materialSettings);
添加图标
这个和前面添加label文字是一样的,我们可以拿到同样的经纬度坐标再转换成xy坐标,之后加个texture进行渲染
- Sprite是一个总是面朝着摄像机的平面,通常含有使用一个半透明的纹理
- 注意点:在遍历json里面的数据记得过滤掉不存在的数据(name为空的)
const createIcon = (point, depth) => {
const texture = new THREE.TextureLoader().load(CityImage);
const material = new THREE.SpriteMaterial({
map: texture,
transparent: true
});
const sprite = new THREE.Sprite(material);
const [x, y] = offsetXY(point);
// 因为这里地图、贴图也是有高度的,所以位置往上拉高点
sprite.position.set(x, -y, depth + 0.5);
sprite.renderOrder = 1;
return sprite;
};
监听点击
在这里由于所有的经纬度都转换成了xy坐标,我们就可以通过拿到鼠标点击的xy位置再去反推点击的是哪一个three对象
- Raycaster(光线投射):用于进行鼠标拾取(在三维空间中计算出鼠标移过了什么物体)
- setFromCamera:入参(在标准化设备坐标中鼠标的二维坐标、射线所来源的摄像机)
- intersectObjects:检查与射线相交的物体
- 到这一步再过滤掉线数据,之后看点击的类型是什么,这个里面也就有我们前面通过
unit.name = name;unit.adcode = adcode;
存的名称编码信息了
window.addEventListener('click', (event) => {
const mouse = new THREE.Vector2();
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
const raycaster = new THREE.Raycaster();
raycaster.setFromCamera(mouse, camera);
const intersects = raycaster
.intersectObjects(map.children)
.filter((item) => item.object.type !== 'Line');
if (intersects.length > 0) {
// 点击市
if (intersects[0].object.type === 'Mesh') {
console.log(intersects[0]);
}
// 点击icon
if (intersects[0].object.type === 'Sprite') {
console.log(intersects[0]);
}
}
});
轮廓动效
在上面添加行政区边界线的时候使用的这段代码创建的一个纹理。
const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
const upLineMaterial = new THREE.LineBasicMaterial({color: '#ffffff'});
接下来改造一下这个纹理:
API分析
- Curve是创建包含插值方法的Curve对象的抽象基类
- CatmullRomCurve3从一系列的点创建一条平滑的三维样条曲线
- 调用CatmullRomCurve3构造(4个参数)
- points – Vector3点数组
- closed – 该曲线是否闭合,默认值为false
- curveType – 曲线的类型,默认值为centripetal。可选(centripetal、chordal和catmullrom)
- tension – 曲线的张力,默认为0.5,类型为catmullrom时有效
- TubeGeometry 管道缓冲几何体,创建一个沿着三维曲线延伸的管道,调用这个构造(5个参数)
- path — Curve - 一个由基类Curve继承而来的3D路径。默认为二次贝塞尔曲线
- tubularSegments — Integer - 组成这一管道的分段数,默认值为64
- radius — Float - 管道的半径,默认值为1
- radialSegments — Integer - 管道横截面的分段数目,默认值为8
- closed — Boolean 管道的两端是否闭合,默认值为false
- ShaderMaterial 着色器材质
- uniforms 定义一些变量
- 注:只有使用 WebGLRenderer 才可以绘制正常, 因为 vertexShader 和 fragmentShader 属性中GLSL代码必须使用WebGL来编译并运行在GPU中
- vertexShader 顶点着色器的GLSL代码
- fragmentShader 片元着色器的GLSL代码
- 并且创建一个全局变量baseMaterialArray存所有的upLineMaterial
创建动画线
const curve = new THREE.CatmullRomCurve3(points, true, 'catmullrom', 0);
const lineGeometry = new THREE.TubeGeometry(
curve,
Math.round(points.length * 0.5),
0.01,
8,
true
);
const upLineMaterial = new THREE.ShaderMaterial({
name: data[0],
uniforms: {
time: {value: 0},//运动时间
len: {value: 0.05},//运动点距离范围
size: {value: 0.2},//管道增加宽度
color1: {value: new THREE.Color('#FFFFFF')},
color2: {value: new THREE.Color('yellow')}
},
vertexShader: `
uniform float time;
uniform float size;
uniform float len;
uniform vec3 color1;
uniform vec3 color2;
varying vec3 vColor;
void main() {
vColor = color1;
vec3 newPosition = position;
float d = uv.x - time;
if(abs(d) < len) {
newPosition = newPosition + normal * size;
vColor = color2;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(newPosition, 1.0);
}`,
fragmentShader: `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}`
});
baseMaterialArray.push(upLineMaterial);
动起来
新加一个方法去改变uniforms当中的time属性从而改变upLineMaterial的展示
// 改变着色器的时间来控制省边界移动
let time = 1;
const animateAction = (material: any) => {
if (material) {
if (time >= 1.0) {
time = 0.0;
}
time = time + 0.000005;
material.uniforms.time.value = time;
}
};
在显示帧当中调用方法去改变
const animate = () => {
// 核心在这,循环调用
baseMaterialArray.forEach((item) => {
animateAction(item);
});
controls.update();
renderer.render(scene, camera);
labelRenderer.render(scene, camera);
requestAnimationFrame(animate);
};
渐变围栏
在createMesh方法当中再创建一个具有高度的shaderMesh。
const material = new THREE.ShaderMaterial({
side: THREE.DoubleSide,
transparent: true,
depthTest: false,
uniforms: {
color1: {value: new THREE.Color('#eba0b3')}
},
vertexShader: `
varying vec2 vUv;
varying vec3 vNormal;
void main() {
vUv=uv;
vNormal=normal;
gl_Position = projectionMatrix * modelViewMatrix * vec4( position, 1.0 );
}`,
fragmentShader: `
uniform vec3 color1;
varying vec2 vUv;
varying vec3 vNormal;
void main() {
if(vNormal.z==1.0||vNormal.z==-1.0||vUv.y ==0.0){
discard;
} else{
gl_FragColor =vec4(color1,mix(1.0,0.0, vUv.y)) ;
}
}`
});
const extrudeSettings = {
depth: 1,
bevelEnabled: false
};
const geometry = new THREE.ExtrudeGeometry(shape, extrudeSettings);
const shaderMesh = new THREE.Mesh(geometry, material);
shaderMesh.position.z = 1;
渐变围栏动效
和前面一样采用通过改变time时间去控制,那这里需要做出改变
- 修改fragmentShader
- 将所有的material保存在一个全局数组当中
- 循环该数组调用前面定义好的animateAction方法即可
// 第一步:修改
fragmentShader: `
uniform vec3 color1;
uniform float time;
uniform float num;
varying vec2 vUv;
varying vec3 vNormal;
void main() {
if(vNormal.z == 1.0 || vNormal.z == -1.0 || vUv.y == 0.0) {
discard;
} else {
// 随着时间移动的多重渐变
gl_FragColor = vec4(color1, 1.0 - fract((vUv.y + time) * num));
}
}`
// 第二步:通过全局数组保存所有的material
let baseLineBorderMaterialArray: THREE.ShaderMaterial[] = [];
baseLineBorderMaterialArray.push(material);
// 第三步:调用
baseLineBorderMaterialArray.forEach((item) => {
animateAction(item);
});
添加泛光效果
- 创建渲染通道 (RenderPass)
- 创建泛光通道 (UnrealBloomPass)
- 参数说明:渲染分辨率、强度(控制泛光的亮度)、半径(控制泛光的扩散范围)、阈值(低于此亮度的像素不会产生泛光效果)
- 创建合成器 (EffectComposer) 用于管理多个渲染通道
- 添加渲染通道
- 添加泛光通道
- 创建输出通道 (OutputPass) 用于最终输出合成后的图像
- 添加输出通道
- 保存合成器实例
- 通过组合多个渲染通道来实现泛光效果,并将其应用于场景中,从而增强视觉效果
// 初始化泛光
let baseCompass: EffectComposer;
const initBloom = () => {
const renderScene = new RenderPass(scene, camera);
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.5,
0.5,
0
);
const composer = new EffectComposer(renderer);
composer.addPass(renderScene);
composer.addPass(bloomPass);
const outputPass = new OutputPass();
composer.addPass(outputPass);
baseCompass = composer;
};
在onMounted方法当中调用initBloom方法,之后修改渲染时机。如果要控制泛光通道作用到那些材质或者物体上,可以通过修改Material当中的visible属性的值来调整。下面是不给湖南这个外围轮廓添加泛光效果。
// 渲染器是否在渲染每一帧之前自动清除其输出
renderer.autoClear = false;
// 让渲染器清除颜色、深度或模板缓存
renderer.clear();
baseLineBorderMaterialArray.forEach((item: THREE.Material) => {
if (item.name === HU_NAN) {
item.visible = false;
}
});
baseCompass.render();
baseLineBorderMaterialArray.forEach((item: THREE.Material) => {
item.visible = true;
});
renderer.render(scene, camera);
可以看到湖南和广西有外围围栏,但是湖南的围栏没有泛光效果
其中轮廓效果来自:https://juejin.cn/post/7343902899095306259