Three.js + 高德地图,实现飞线、运动边界和炫酷标牌

文化   2024-10-11 07:00   河南  

前言

地图经常使用标牌来标注地点位置,是时候来点炫酷的效果了!用高德地图+Three.js,带你实现飞线、运动边界和炫酷标牌。

实现思路

一、高德地图+Three.js
可以参考官方示例 高德地图-自定义图层-GLCustomLayer 结合 THREE
  • 初始化高德3D地图
 this.map = new AMap.Map(this.container, {      //缩放范围      zooms: [2, 20],      zoom: 4.5,      //倾斜角度      pitch: 0,      //隐藏标签      showLabel: false,      //3D地图模式      viewMode: '3D',      //初始化地图中心点      center: this.center,      //暗色风格      mapStyle: 'amap://styles/dark'    });   
  • 自定义高德地图经纬度转px工具,即墨卡托投影坐标转换
   // 数据转换工具    this.customCoords = this.map.customCoords;    //设置坐标转换中心    this.customCoords.setCenter(this.center);
  • 高德地图添加WebGL自定义图层,初始化Three.js配置
 // 创建 GL 图层    var gllayer = new AMap.GLCustomLayer({      //zIndex: 10,      // 初始化的操作,创建图层过程中执行一次。      init: (gl) => {        //初始化Three相机        this.camera = new THREE.PerspectiveCamera(          60,          this.container.offsetWidth / this.container.offsetHeight,          1,          1 << 30        );        //初始化Three渲染器        this.renderer = new THREE.WebGLRenderer({          context: gl // 地图的 gl 上下文        });        this.renderer.setPixelRatio(window.devicePixelRatio);        // 自动清空画布这里必须设置为 false,否则地图底图将无法显示        this.renderer.autoClear = false;        //初始化场景        this.scene = new THREE.Scene();          this.createChart();      },      render: () => {        // 这里必须执行!!重新设置 three 的 gl 上下文状态。        this.renderer.resetState();        //设置坐标转换中心        this.customCoords.setCenter(this.center);        var { near, far, fov, up, lookAt, position } = this.customCoords.getCameraParams();
// 这里的顺序不能颠倒,否则可能会出现绘制卡顿的效果。 this.camera.near = near; this.camera.far = far; this.camera.fov = fov; this.camera.position.set(...position); this.camera.up.set(...up); this.camera.lookAt(...lookAt); this.camera.updateProjectionMatrix(); //渲染器渲染场景 this.renderer.render(this.scene, this.camera); // 这里必须执行!!重新设置 three 的 gl 上下文状态。 this.renderer.resetState(); } });    this.map.add(gllayer);
二、绘制运动边界
行政区边界可以通过地理小工具获取。

https://datav.aliyun.com/portal/school/atlas/area_selector

高德地图折线polyline经常使用,但是让折线运动起来,你可能很少其实只需要添加几行代码就能实现。
实现逻辑:添加边界折线,然后截取折线的一段,让它跟着时间往后移动,即开始索引递增,截取相同折线,即可让折线运动起来。
 //绘制中国大陆运动边界        createChinaLine() {          return new Promise((resolve) => {            fetch('https://geo.datav.aliyun.com/areas_v3/bound/100000.json')              .then((res) => res.json())              .then((res) => {                let path = res.features[0].geometry.coordinates[0][0];                //截取10%的线段                let len = Math.floor(path.length * 0.1);                //边界折线                let polyline = new AMap.Polyline({                  path: path,                  strokeWeight: 4,                  strokeColor: 'white',                  lineJoin: 'round',                  strokeOpacity: 1                });                this.map.add(polyline);                //利用Tween创建动画                new TWEEN.Tween({ start: 0 })                  .to({ start: path.length }, 3000)                  //无限循环动画                  .repeat(Infinity)                  .onUpdate((obj) => {                    if (obj.start + len < path.length) {                      polyline.setPath(path.slice(obj.start, obj.start + len));                    } else {                      const c = path.length - obj.start;                      //头尾相接时截取尾部+头部各一段                      polyline.setPath(                        [].concat(path.slice(obj.start, path.length), path.slice(0, len - c))                      );                    }                  })                  .start();                resolve();              });          });        }        //Tween动画         animateAction() {          if (TWEEN.getAll().length) {            TWEEN.update();          }          }

运动边界实现简单,但效果一级棒!并且2D和3D地图都能用,优秀!

三、绘制升起山峰

在对应的坐标点上升起一座尖尖的山峰,这里使用Plane平面实现。
  • 顶点着色器
precision mediump float;uniform float uTime;uniform float uHeight;varying float vD;float PI = acos(-1.0);vec2 center = vec2(0.5);void main(void) {//离中线的距离    float d = length(uv - center) * 2.0;    //沿中心点往外减少    vD = pow(1.0 - d, 3.0);    //山峰高度,随着uTime变化    float h = vD * uHeight * uTime;    vec3 pos = vec3(position.x * 0.5, position.y * 0.5, h);      gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);}
  • 片元着色器

precision mediump float;uniform vec3 uColor;varying float vD;void main(void) {    if(vD < 0.01)//透明度太小则不渲染颜色        discard;    else//透明度随着距离中心点的变化        gl_FragColor = vec4(uColor, vD * 2.0);}
  • 添加shaderMaterial的平面
 const material = new THREE.ShaderMaterial({            uniforms: {            //随时间变化              uTime: { value: 0 },              //高度              uHeight: { value: this.size },              //颜色              uColor: { value: new THREE.Color(data.color) }            },            //开启透明度            transparent: true,            vertexShader: ``,            fragmentShader: ``          });          this.material = material;           //平面形状,方便复用          if (!this.ageometry) {                   //平面的面数一定要足够才能形成山峰            const geometry = new THREE.PlaneGeometry(this.size, this.size, 500, 500);            this.ageometry = geometry;          }          const plane = new THREE.Mesh(this.ageometry, material);          //转换经纬度作为px          const d = this.customCoords.lngLatToCoord(data.pos);          plane.position.set(d[0], d[1], 0);          this.scene.add(plane);

 注意:

1、平面的面数一定要足够才能形成山峰。
2、平面的大小和高度要足够大,在中国地图上才能看到,这里的
this.size=500000,500k。在小的地图范围内,大小也要对应缩小。
3、高德地图坐标与three.js的坐标有些许不同,经纬度
lng,lat对应xy,而高度对应z坐标。

其中提到的高度数据为虚拟数据。

  • 添加山峰升起动画

 addAnimate(start, end, time, update) {          return new Promise((resolve) => {            const tween = new TWEEN.Tween(start)              .to(end, time)              .onUpdate(update)              .easing(TWEEN.Easing.Quadratic.In)              .onComplete(() => {                resolve(tween);              })              .start();          });        }
const tw = await this.addAnimate({ time: 0 }, { time: 1 }, 1000, (obj) => { this.material.uniforms.uTime.value = obj.time; }); //播放完删除动画            TWEEN.remove(tw);


四、绘制浮动四棱锥
//转换经纬度坐标 const d = this.customCoords.lngLatToCoord(data.pos);           const r = this.size * 0.1;          //四棱锥图形,方便复用          if (!this.cgeometry) {            const geometry = new THREE.ConeGeometry(r, r * 2, 4, 1);            this.cgeometry = geometry;          }
const material = new THREE.MeshLambertMaterial({ color: new THREE.Color(data.color) }); //创建四棱锥网格 const cone = new THREE.Mesh(this.cgeometry, material); //旋转90度,让四棱锥倒立 cone.rotateX(-Math.PI * 0.5); //设置位置 cone.position.set(d[0], d[1], this.size * 1.1);
this.scene.add(cone); //收集四棱锥          this.cones.push({ obj: cone, step: this.speed });
  • 设置垂直高度坐标,让四棱锥上下浮动:遇到最大或最小高度时改变速度speed方向。
 animateAction() {                    if (this.cones.length) {            this.cones.forEach((c) => {            //高低浮动              if (c.obj.position.z >= this.maxHeight) {//最大高度                c.step = -this.speed;              } else if (c.obj.position.z <= this.minHeight) {//最小高度                c.step = this.speed;              }
c.obj.position.z += c.step; }); }        }


五、绘制文本标牌

原本尝试用Marker的自定义内容content来实现html标签的,但文档上说Marker高度属性height只有在2.1版本生效,但目前只有2.0版本,暂时无法使用高度属性。

通过咨询高德地图官方,官方推荐用Loca数据可视化里面的ZMarkerLayer。

以下代码来源于 高德地图-Loca API标牌点-某片区房价信息

https://lbs.amap.com/demo/loca-v2/demos/zmarker/house-price

      var triangleZMarker = new Loca.ZMarkerLayer({          loca: loca,          zIndex: 119,          depth: false,      });      triangleZMarker.setSource(geo);//设置数据集      triangleZMarker.setStyle({          content: (i, feat) => {//html自定义内容              return (                  '<div style="width: 120px; height: 120px; background: url(/cover?u=VCs0NCsvZ05mSzVWeVBLeGQrNVA0Y0ZKVnlJbkNpcy9TcjRxblFYUmw2c3dka0xLVmNieEJ6cVB1SEJvZlZBTlZvOHhaS0ZtZjRwb0lDcWkyVllPVUxLcHcrcTFaczVETjBSOTJ4M01rVlhKeXpsY0s2VVkwSjJOSHV2VUtvNlpiUkYvdkNDbC81Q3lPU0JpWmcrWUR0MlBZT2NSODlCaEtIaU5keHdBQkR4ZzRhdTdUeFdSS3RNVk1oOThvRHZkYmVHMGdDbG5ydU52Yk5RSk5kaVFxUjhyTndiY0Rpck44cUl2MkVaSjBxTzd6SVVzLzI2cVowMzA5dVNlcWl0aGJSRi92Q0NsLzVDeU9TQmlaZytZRHQyUFlPY1I4OUJoS0hpTmR4d0FCRHhnNGF1N1R4V1JLdE1WTWg5OG9EdmR6OVp2ekhRVjJGOEkyZkNmSTRmOExGV2V2SW1kZ3Z2aDdsQkRQSm9JcXBYN3VkVXJmbkl2ZDZLWUlvamRkU0lhQ0RESi9yWkRieXZBS3N2TnFPejhVeDhyTndiY0Rpck44cUl2MkVaSjBxUHRYa2Iya3FZQlRsL2xSd3lobHpNcnk5SVRUSTdtbWhLRjZiZURrdWZPdjJVT21oVXlJRGN4Qjh4bEQ3YW1La1ZvQ01Nd01VMTdJWnRuL3pjbWY0V055UU5zZWh1Z3FYOTNNTTBFWTlMOU1GNTA4dGJIL1lxRGZUbkpjMENVLzAzOExRTHJUZnp2bGR1WkUxYXUwTnFtblFvUmN0eTBEVkRWamtCSnJFeTBMSkJna2JXNW1TbW96bVFwN3oyZzRDVFQrMjAxdC9RcStCbTR4Unh1ZDVNZXRsMmlUc21CTURZZmJZa3grNEgveklHbU1HeEZlWVIwYWgySVJ0YXhRZ3NHUWo2a2xReXdQdlFLcTkwajFXaitXakcwb1hIZU1JOUNkaERIMkZlZHk1MDV6M0dVcHZEdDFsM2xra0s5T3lGS3pxNERHaEVHUnFoS2dGd1hsbUpkck9JS3Q2UkFSVnU0R2VWako5eVNHVUE3QS9pVWtLQXVMMTI5SzRTRXJHdmtvMWFBcnN2QkRrR0k4M3hTVkJobnF0RmdXQ1F5M005YWFkRTd4L01pOUlHUEhKN215R2swSld0OURlTFF0YUU9)                  + '.png);"></div>'              );          },          unit: 'meter',          rotation: 0,          alwaysFront: true,          size: [60, 60],          altitude: 15,//高度      });      triangleZMarker.addAnimate({//动画          key: 'altitude',          value: [0, 1],          random: true,          transform: 1000,          delay: 2000,          yoyo: true,          repeat: 999999,      }); 
ZMarker添加整一个图层Layer生成一批Marker,官方的示例很漂亮。因为我想控制一个个的Marker,故改用Three.js的CSS2DRender来实现。

在GLCustomLayer的init中添加初始化CSS2DRender

 let labelRenderer = new CSS2DRenderer();        labelRenderer.setSize(container.offsetWidth, container.offsetHeight);        labelRenderer.domElement.style.position = 'absolute';        labelRenderer.domElement.style.top = '0px';        //不妨碍界面上点击冲突        labelRenderer.domElement.style.pointerEvents = 'none';        this.container.appendChild(labelRenderer.domElement);        this.labelRenderer = labelRenderer;
  • 在GLCustomLayer的init中添加CSS2DRender渲染

this.labelRenderer.render(this.scene, this.camera);
  • 添加Label标牌

 addLabel(dom, pos) {   //label的dom可以触发事件  dom.style.pointerEvents = 'auto';    const label = new CSS2DObject(dom);    label.position.set(...pos);    this.scene.add(label);    return label;  }
addALabel(data) { const div = document.createElement('div'); div.innerHTML = `<div class="tip-box" style="background:${data.bg};--base-color:${data.color}"><span class="circle" ></span><span class="text">${data.name}</span></div>`; //坐标转换 const d = this.customCoords.lngLatToCoord(data.pos); const label = this.addLabel(div, [d[0], d[1], this.size * 1.5]);          }
  • 标牌动画和样式直接用css
//变大弹出@keyframes big {        0% {          transform: scale(0);        }        100% {          transform: scale(1);        }      }      //闪烁      @keyframes flash {        0% {          opacity: 0.3;        }        100% {          opacity: 1;        }      }
.tip-box { --base-color: dodgerblue; border: solid 1px var(--base-color); background-color: rgba(30, 144, 255, 0.3); color: white; white-space: nowrap; padding-left: 8px; padding-right: 16px; height: 32px; animation: big 1s ease-in; border-radius: 16px; display: flex; align-items: center; box-shadow: 0 0 8px var(--base-color); } .tip-box .circle { background-color: var(--base-color); height: 16px; width: 16px; border-radius: 50%; animation: flash 0.5s ease-in alternate infinite; } .tip-box .text { margin: 0 8px;      }
 注意:

DOM对象上如果有动画css请在外部包裹一层,避免css样式冲突,导致动画失效。


六、绘制飞线

飞线由管道形状绘制,通过着色器来改成头大尾小的形状。

  • 顶点着色器

float PI = acos(-1.0);varying float vT;varying float vS;uniform float uTime;uniform float uSize;uniform float uLen;void main(void) {    //取模循环    float d = mod(uv.x - uTime, 1.0);    //截取uLen长度    vS = smoothstep(0.0, uLen, d);    //不在范围内不渲染    if(vS < 0.01 || d > uLen)        return;    //头大尾小的飞线坐标点    vec3 pos = position + normal * sin(PI * 0.5 * (vS - 0.6)) * uSize;    gl_Position = projectionMatrix * modelViewMatrix * vec4(pos, 1.0);}
  • 片元着色器

varying float vS; uniform vec3 uColor; void main(void) {//透明度随着飞线头尾变小    gl_FragColor = vec4(uColor, vS);}
  • 添加管道飞线,用贝塞尔曲线形成弯曲

addLine(posList, color) {//经纬度坐标点统一转换          const d = this.customCoords.lngLatsToCoords(posList);          //贝塞尔曲线形成弯曲          const curve = new THREE.QuadraticBezierCurve3(            new THREE.Vector3(d[0][0], d[0][1], 0),            //取中间点            new THREE.Vector3((d[0][0] + d[1][0]) * 0.5, (d[0][1] + d[1][1]) * 0.5, this.size),            new THREE.Vector3(d[1][0], d[1][1], 0)          );          const geometry = new THREE.TubeGeometry(curve, 32, 10000, 8, false);          const material = new THREE.ShaderMaterial({            uniforms: {            //随时间变化              uTime: { value: 0.0 },              //飞线长度              uLen: { value: 0.6 },              //飞线宽度              uSize: { value: 10000 },              //飞线颜色              uColor: { value: new THREE.Color(color) }            },            //开启透明度            transparent: true,
vertexShader: ``, fragmentShader: `` });
const line = new THREE.Mesh(geometry, material); this.scene.add(line);        }
  • 添加飞线动画,让飞线动起来

  new TWEEN.Tween({ time: 0 })            .to({ time: 1.0 }, 1000)            //重复动画            .repeat(Infinity)            .onUpdate((obj) => {              material.uniforms.uTime.value = obj.time;            })            .start();


七、动画连续起来

最终效果:地图绘制运动边界,镜头放大倾斜,对应地点长出尖尖的“山峰”,然后弹出一个上下浮动四棱锥和文本标牌,随即生出一条飞线,镜头跟随,跳到下一个景点,再次弹出四棱锥和文本标牌,重复,走过所有地点后,定格到最终视角。

//运动边界线          await this.createChinaLine();          //绘制山峰          await this.createA(this.tags[0]);          //视角变化          this.map.setPitch(68, false, 3000);          this.map.setRotation(24, false, 3000);          await this.sleep(2000);          {             //升起山峰            const tw = await this.addAnimate({ time: 0 }, { time: 1 }, 1000, (obj) => {              this.material.uniforms.uTime.value = obj.time;            });                        TWEEN.remove(tw);            //添加标牌            this.addALabel(this.tags[0]);          }          await this.sleep(2000);          //绘制飞线          this.addLine([this.tags[0].pos, this.tags[1].pos], this.tags[0].color);          for (let i = 1; i < this.tags.length; i++) {            const data = this.tags[i];            //视角跟随到新地点            this.map.setCenter(data.pos, false, 1000);            this.map.setZoom(6, false, 1000);            await this.sleep(1000);            //绘制山峰            await this.createA(data);            //升起山峰            const tw = await this.addAnimate({ time: 0 }, { time: 1 }, 1000, (obj) => {              this.material.uniforms.uTime.value = obj.time;            });            TWEEN.remove(tw);            this.addALabel(data);            await this.sleep(1000);            //添加飞线            if (i < this.tags.length - 1) {              this.addLine([data.pos, this.tags[i + 1].pos], data.color);            } else {              //最终视角              this.map.setPitch(73.2, false, 3000);              this.map.setZoom(5, false, 1000);              this.map.setRotation(58.7, false, 3000);              this.map.setCenter([101.6, 35.6], false, 1000);            }          }

高德地图3D地图视角设置真的很方便,还自带动画功能,效果真的很棒!

setZoom设置缩放大小
setPitch设置倾斜角度
setCenter设置地图中心经纬度
setRotation设置旋转角度

八、GitHub地址

https://github.com/xiaolidan00/my-earth

高德开放平台第一期实战案例,一等奖作品
作者:萧利丹

仅代表作者个人观点


感谢您的阅读      

在看点赞 好文不断  

初识Threejs
初识 Three.js 的奇妙世界,走进三维空间,与小编一起拥抱前端技术,涉及WebGL、WebGPU、Threejs、Shader、GIS、VR/AR、数字孪生、3D图形学等。
 最新文章