用threejs 写的一个简单的 3D地球

前言

最近这几天简单的研究了一下threejs,在看了看大佬们写的3D地球,想着只研究不写点案例就有点手痒的原则,就简单的写了一个3D地球 部分代码借鉴了大佬的开源项目代码 用的技术栈 vue3 + typescript + vite

一、环境搭建

首先就是 搭建场景、渲染器、相机、灯光等。这些是开发threejs项目的基础必备模块,这里就不多说了,能看到这个文章的肯定都是知道的。我将这些初始化操作都分开了,有兴趣的可以看看源码

typescript 复制代码
  /**
   * 初始化3D场景
   */
  init() {
    // 初始化渲染器
    this.renderer = initRenderer(this.width, this.height)
    // 初始化场景
    this.scene = initScene();
    // 初始化相机
    this.camera = initCamera(this.width, this.height);
    this.parentDom.appendChild(this.renderer.domElement);
    // 初始化光源
    initLight(this.scene);
    // 初始化轨道控制
    this.orbitControl = initControls(this.camera, this.renderer)

    if (GlobalConfig.default.showStats) {
      this.stats = new Stats();
      this.parentDom.appendChild(this.stats.dom);
    }
  }
  
   /**
   * 场景渲染
   */
  animate() {
    if (GlobalConfig.default.star.show && GlobalConfig.default.star.autoRotate && this.stars) {
      this.stars.rotation.y += 0.0001
    }
    if (GlobalConfig.default.earth.autoRotate && this.earthObj) {
      this.earthObj.rotation.y += 0.001
    }
    if (this.stats && GlobalConfig.default.showStats) {
      this.stats.update()
    }
    this.renderer.clear();
    this.renderer.render(this.scene, this.camera);
    requestAnimationFrame(this.animate.bind(this));
    this.afterAnimate();
  }

二、创建星空背景

  • 为了让3D地球展示起来没有这么光秃秃,加一个星空的效果会更加的好看
  • 由于星空效果会有很多的点,这里使用BufferGeometry来减少数据开销,大小、位置这些就随机在一定范围内生成了
  • 使用了一张白色光点的图片作为贴图,配合点材质PointsMaterial来作为星空的材质
typescript 复制代码
export const initStarBg = (scene: Scene) => {
  const texture = new TextureLoader().load(gradient);
  const positions = [];
  const colors = [];
  const sizes = []
  const geometry = new BufferGeometry();
  for (let i = 0; i < 10000; i++) {
    let vertex = new Vector3();
    vertex.x = Math.random() * 2 - 1;
    vertex.y = Math.random() * 2 - 1;
    vertex.z = Math.random() * 2 - 1;
    positions.push(vertex.x, vertex.y, vertex.z);
    let color = new Color();
    color.setHSL(
      Math.random() * 0.2 + 0.5,
      0.55,
      Math.random() * 0.25 + 0.55
    );
    colors.push(color.r, color.g, color.b);
    sizes.push(Math.random() * 10)
  }

  geometry.setAttribute("position", new Float32BufferAttribute(positions, 3));
  geometry.setAttribute("color", new Float32BufferAttribute(colors, 3));
  geometry.setAttribute('size', new Float32BufferAttribute(sizes, 1).setUsage(DynamicDrawUsage));

  const shaderMaterial = new PointsMaterial({
    map: texture,
    size: 10,
    transparent: true,
    opacity: 1,
    vertexColors: true,
    blending: AdditiveBlending,
    sizeAttenuation: true,
  });

  const stars = new Points(geometry, shaderMaterial);
  stars.name = 'star'
  stars.scale.set(2400, 2400, 2400);
  scene.add(stars);
  return stars
}
  • 再创建好点之后,通过scale进行放大处理,就可以达到星空的效果
  • 做出来差不多就这样,再简单的加一个背景图,星空效果就做好了

三、创建地球

  • 地球其实就是一个 球体 + 贴图
  • 贴图我是百度图库随便找的一个,看起来还可以
typescript 复制代码
  const earthObj = new Object3D();
  const texture = new TextureLoader().load(earthImg);
  const lightMap = new TextureLoader().load(earthLight);
  let earthOutLine: Object3D | null = null
  let flyManager: InitFlyLine;
  let waveMeshObj: Group;

  const globeGgeometry: SphereGeometry = new SphereGeometry(GlobalConfig.earthRadius, 100, 100);
  const globeMaterial: MeshStandardMaterial = new MeshStandardMaterial({
    map: texture,
    lightMap: lightMap,
    flatShading: true,
    fog: false,
  });

  const globeMesh = new Mesh(globeGgeometry, globeMaterial);
  globeMesh.name = 'earth'
  // earthObj.rotation.set(0.5, 2.9, 0.1);
  earthObj.add(globeMesh);
  • 代码很简单,就不多说了,创建好了添加到场景里面去就行了

四、创建中国描边和流光动效

中国描边

  • 这里是根据geojson的数据来生成地图的,简单的将各个省份这些进行了画线
  • 要数据的话可以直接去 datav.aliyun.com/portal/scho... 自行下载
  • 通过循环遍历geojson中的省份数据,将对应的经纬度转换为球面上的坐标xyz,根据这些坐标使用Line生成轮廓线条
typescript 复制代码
export const createMapStroke = () => {
  const cityStroke = new Object3D();
  cityStroke.name = "cityStroke";

  const lineMaterial = new LineBasicMaterial({
    color: 0xf19553,
  });

  chinaInfoJson.features.forEach((elem: any) => {
    const provinceLine = new Group();
    provinceLine.name = elem.properties.name;
    const coordinates = elem.geometry.coordinates;
    coordinates.forEach((multiPolygon: any) => {
      multiPolygon.forEach((polygon: any) => {
        const line = createCityLine(polygon, lineMaterial);
        provinceLine.add(line);
      });
    });
    cityStroke.add(provinceLine);
  })

  return { cityStroke }
}

/**
 * 球面画线
 * @param polygon 
 * @param lineMaterial 
 */
export const createCityLine = (polygon: any, lineMaterial: LineBasicMaterial) => {
  const positions = [];
  const linGeometry = new BufferGeometry();
  for (let i = 0; i < polygon.length; i++) {
    let pos = lglt2xyz(polygon[i][0], polygon[i][1]);
    positions.push(pos.x, pos.y, pos.z);
  }
  linGeometry.setAttribute(
    "position",
    new Float32BufferAttribute(positions, 3)
  );

  return new Line(linGeometry, lineMaterial)
}

/**
 * @description 经纬度转换球面坐标
 * @param longitude 
 * @param latitude 
 */
export const lglt2xyz = (longitude: number, latitude: number) => {
  const theta = (90 + longitude) * (Math.PI / 180);
  const phi = (90 - latitude) * (Math.PI / 180);
  return new Vector3().setFromSpherical(
    new Spherical(GlobalConfig.earthRadius, phi, theta)
  );
}
  • 搞出来效果就是下面这样

流光动效

  • 在上面的地址里面去下载中国外边框的geojson数据
  • 这里是参考了另一位大佬的代码,具体就不过多描述了
  • 主要实现方式类似于上面中国描边的方式,但是是生成Points,并且通过Shader来生成流光效果,并通过动画进行跑动
typescript 复制代码
export const createEarthOutLine = () => {
  const map = new Object3D();
  chinaOutLineJson.features.forEach((elem) => {
    const province = new Object3D();
    const coordinates = elem.geometry.coordinates;
    coordinates.forEach((multiPolygon) => {
      multiPolygon.forEach((polygon) => {
        if (polygon.length > 200) {
          let v3ps = [];
          for (let i = 0; i < polygon.length; i++) {
            let pos = lglt2xyz(polygon[i][0], polygon[i][1]);
            v3ps.push(pos);
          }
          let curve = new CatmullRomCurve3(v3ps, false);
          let color = new Vector3(
            0.5999758518718452,
            0.7798940272761521,
            0.6181903838257632
          );
          let flyLine = initFlyLine(
            curve,
            {
              speed: 0.5,
              color: color,
              number: 3, //同时跑动的流光数量
              length: 0.2, //流光线条长度
              size: 3, //粗细
            },
            5000,
          );
          province.add(flyLine);
        }
      });
    });
    map.add(province);
  });

  return map;
}

export const initFlyLine = (curve: CatmullRomCurve3, matSetting: any, pointsNumber: number) => {
  const points = curve.getPoints(pointsNumber);
  const geometry = new BufferGeometry().setFromPoints(points);
  const length = points.length;
  let percents = new Float32Array(length);
  for (let i = 0; i < points.length; i += 1) {
    percents[i] = i / length;
  }
  geometry.setAttribute("percent", new BufferAttribute(percents, 1));
  const lineMaterial = initLineMaterial(matSetting);
  const flyLine = new Points(geometry, lineMaterial);
  return flyLine;
}
  • 核心的关键就是Shader
typescript 复制代码
const lineMaterial = new ShaderMaterial({
    uniforms: {
      time: { type: "f", value: 0.0 },
      number: { type: "f", value: number },
      speed: { type: "f", value: speed },
      length: { type: "f", value: length },
      size: { type: "f", value: size },
      color: { type: "v3", value: color },
    },
    vertexShader: `
      varying vec2 vUv;
      attribute float percent;
      uniform float time;
      uniform float number;
      uniform float speed;
      uniform float length;
      varying float opacity;
      uniform float size;
      void main()
      {
          vUv = uv;
          vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
          float l = clamp(1.0-length,0.0,1.0);
          gl_PointSize = clamp(fract(percent*number + l - time*number*speed)-l ,0.0,1.) * size * (1./length);
          opacity = gl_PointSize/size;
          gl_Position = projectionMatrix * mvPosition;
      }
    `,
    fragmentShader: `
      #ifdef GL_ES
      precision mediump float;
      #endif
      varying float opacity;
      uniform vec3 color;
      void main(){
          if(opacity <=0.2){
              discard;
          }
          gl_FragColor = vec4(color,1.0);
      }
    `,
    transparent: true,
    blending: AdditiveBlending,
  });

五、创建标记点和波纹

  • 标记点和波纹都是通过创建平面缓冲几何体PlaneGeometry外加贴图来实现
  • 标记点的点位都是通过转换经纬度为球面坐标得到的
typescript 复制代码
export const createCityPoints = (cityList: ICityList) => {
  const waveMeshArr: Group = new Group()
  waveMeshArr.name = 'cityPointWaveGroup'
  const pointMeshArr: Group = new Group()
  pointMeshArr.name = 'cityPointGroup'
  let texture = new TextureLoader().load(biaozhu);
  let texture2 = new TextureLoader().load(bzguangquan);
  for (const cityName in cityList) {
    let city = cityList[cityName];
    let lon = city.longitude;
    let lat = city.latitude;
    let position = lglt2xyz(lon, lat);
    let waveMesh = createWaveMesh(position, texture2)
    let pointMesh = createPointMesh(position, texture)
    waveMeshArr.add(waveMesh)
    pointMeshArr.add(pointMesh)
  }
  return { waveMeshArr, pointMeshArr }
}

/**
 * @description 创建标记点
 * @param position 
 * @param texture 
 */
export const createPointMesh = (position: Vector3, texture: Texture) => {
  const planeGeometry: PlaneGeometry = new PlaneGeometry(1, 1);
  const material: MeshBasicMaterial = new MeshBasicMaterial({
    color: '#6edade',
    map: texture,
    transparent: true,
    opacity: 1.0,
    depthWrite: false,
  })
  let mesh: Mesh = new Mesh(planeGeometry, material);
  let size: number = GlobalConfig.earthRadius * 0.035;
  mesh.scale.set(size, size, size);

  mesh.position.set(position.x, position.y, position.z);
  mesh.privateType = "cityPoint";
  mesh.layerType = "city";
  let coordVec3 = new Vector3(position.x, position.y, position.z).normalize();
  let meshNormal = new Vector3(0, 0, 1);
  mesh.quaternion.setFromUnitVectors(meshNormal, coordVec3);
  // mesh.userData.quaternion = mesh.quaternion;
  return mesh;
}
  • 波纹的动画是通过修改波纹mesh的scale以及材质透明度来达到的
typescript 复制代码
/**
 * @description 波纹动画
 * @param waveMeshArr 
 */
export const waveMeshAnimate = (waveMeshArr: any) => {
  if (!waveMeshArr || !waveMeshArr?.children?.length) return false;
  waveMeshArr.children.forEach((mesh: Mesh) => {
    mesh._s += 0.005;
    mesh.scale.set(
      mesh.size * mesh._s,
      mesh.size * mesh._s,
      mesh.size * mesh._s
    );
    if (mesh._s <= 1.3) {
      mesh.material.opacity = (mesh._s - 1) * 2;
    } else if (mesh._s > 1.3 && mesh._s <= 1.6) {
      mesh.material.opacity = 1 - (mesh._s - 1.3) * 2;
    } else {
      mesh._s = 1.0;
    }
  });
}

六、标记点连线与飞线

  • 已经知道一条飞线的首尾两点的经纬度,首先将经纬度转为坐标,再通过这两个点计算得到中间点,创建一条三维三次贝塞尔曲线
  • 这里我将曲线分成了50段,并存下来这50段对应的点位
  • Line的颜色我写的一个渐变色,感觉好看一点点
  • 最后创建一个线Line,写得很简单
typescript 复制代码
export const addCityLine = (v0: Vector3, v3: Vector3) => {
  const { v1, v2 } = getCubicBezierCenterPoint(v0, v3)
  let curve = new CubicBezierCurve3(v0, v1, v2, v3)

  let points = curve.getSpacedPoints(50);
  let positions = [];
  let colors = [];
  let color = new Color();

  for (let j = 0; j < points.length; j++) {
    color.setHSL(.31666 + j * 0.005, 0.7, 0.5); //绿色
    colors.push(color.r, color.g, color.b);
    positions.push(points[j].x, points[j].y, points[j].z);
  }
  let geometry = new BufferGeometry().setFromPoints(points);
  geometry.setAttribute('position', new Float32BufferAttribute(positions, 3));
  geometry.setAttribute('color', new Float32BufferAttribute(colors, 3));
  let matLine = new LineBasicMaterial({
    linewidth: 0.0016,
    vertexColors: true,
  });
  const mesh = new Line(geometry, matLine)
  return mesh
}
  • 飞线的话,也是同样使用的三维三次贝塞尔曲线,主要是确保飞线会和标记点连线重叠上,并且根据首位点的距离分成了对应的段数
  • 关于具体飞线的实现,是参考了别的大佬的代码,这就不过多阐述了
typescript 复制代码
export const addFlyLine = (
  earthObj: Object3D,
  flyManager: InitFlyLine,
  fromCity: ICity,
  toCity: ICity,
  color: string,
) => {
  const curvePoints = new Array();
  let fromXyz = lglt2xyz(fromCity.longitude, fromCity.latitude);
  let toXyz = lglt2xyz(toCity.longitude, toCity.latitude);

  curvePoints.push(new Vector3(fromXyz.x, fromXyz.y, fromXyz.z));

  let distanceDivRadius =
    Math.sqrt(
      (fromXyz.x - toXyz.x) * (fromXyz.x - toXyz.x) +
      (fromXyz.y - toXyz.y) * (fromXyz.y - toXyz.y) +
      (fromXyz.z - toXyz.z) * (fromXyz.z - toXyz.z)
    ) / GlobalConfig.earthRadius
  let partCount = 3 + Math.ceil(distanceDivRadius * 3);

  const { v1, v2 } = getCubicBezierCenterPoint(fromXyz, toXyz)

  let curve = new CubicBezierCurve3(fromXyz, v1, v2, toXyz);

  let pointCount = Math.ceil(500 * partCount);

  let allPoints = curve.getPoints(pointCount);

  let flyMesh = flyManager.addFly({
    curve: allPoints,
    color: color,
    width: 4,
    length: Math.ceil((allPoints.length * 3) / 15),
    speed: partCount + 20,
    repeat: Infinity,
  });

  earthObj.add(flyMesh)
}
  • 飞线的效果是通过Shader来实现的
typescript 复制代码
this.flyShader = {
      vertexshader: ` 
        uniform float size; 
        uniform float time; 
        uniform float u_len; 
        attribute float u_index;
        varying float u_opacitys;
        void main() { 
            if( u_index < time + u_len && u_index > time){
                float u_scale = 1.0 - (time + u_len - u_index) /u_len;
                u_opacitys = u_scale;
                vec4 mvPosition = modelViewMatrix * vec4(position, 1.0);
                gl_Position = projectionMatrix * mvPosition;
                gl_PointSize = size * u_scale * 300.0 / (-mvPosition.z);
            } 
        }
        `,
      fragmentshader: ` 
        uniform sampler2D u_map;
        uniform float u_opacity;
        uniform vec3 color;
        uniform float isTexture;
        varying float u_opacitys;
        void main() {
            vec4 u_color = vec4(color,u_opacity * u_opacitys);
            if( isTexture != 0.0 ){
                gl_FragColor = u_color * texture2D(u_map, vec2(gl_PointCoord.x, 1.0 - gl_PointCoord.y));
            }else{
                gl_FragColor = u_color;
            }
        }`
    }

总结

  • 上面这些是我现在最开始研究了一两天的threejs写的一个简单的案例,并参考了数位大佬的代码,后面自己将会按照我的一点小想法再写点其它的什么东西。
  • 以前在公司做过结合UE4云渲染 + 前端实现的一个 地球 > 中国 > 省份 > 城市 > 园区 > 机房 > 机柜 再到等等等,反正看着挺好看的,看看自己有空通过这个threejs能不能实现一个差不多的效果

源码地址,看看就行

相关推荐
艾小逗1 小时前
vue3中的effectScope有什么作用,如何使用?如何自动清理
前端·javascript·vue.js
小小小小宇4 小时前
手写 zustand
前端
Hamm4 小时前
用装饰器和ElementPlus,我们在NPM发布了这个好用的表格组件包
前端·vue.js·typescript
小小小小宇5 小时前
前端国际化看这一篇就够了
前端
大G哥5 小时前
PHP标签+注释+html混写+变量
android·开发语言·前端·html·php
whoarethenext5 小时前
html初识
前端·html
小小小小宇5 小时前
一个功能相对完善的前端 Emoji
前端
m0_627827525 小时前
vue中 vue.config.js反向代理
前端
Java&Develop5 小时前
onloyoffice历史版本功能实现,版本恢复功能,编辑器功能实现 springboot+vue2
前端·spring boot·编辑器
白泽talk5 小时前
2个小时1w字| React & Golang 全栈微服务实战
前端·后端·微服务