【Threejs 项目实战】这可能是你见过最漂亮的3D地球!!!

11,613 阅读4分钟

threejs 项目实战 - 科技3D地球

本项目使用Webpack 5 + Typescript 4 + Threejs + Shader 基础模板 搭建

查看效果

源码地址

threejs基础ts模板

threejs基础概念简单介绍

Threejs和Webgl的关系

Three.js经常会和WebGL混淆, 但也并不总是,three.js其实是使用WebGL来绘制三维效果的。 WebGL是一个只能画点、线和三角形的非常底层的系统. 想要用WebGL来做一些实用的东西通常需要大量的代码, 这就是Three.js的用武之地。它封装了诸如场景、灯光、阴影、材质、贴图、空间运算等一系列功能,让你不必要再从底层WebGL开始写起。

基础threejs场景

一个最基础的Three.js程序包括渲染器(Renderer)、场景(Scene)、相机(Camera)、灯光(灯光),以及我们在场景中创建的物体(Earth)。

截图 截图

此次主要是项目实战,其他理论基础知识请前往官方文档

入口文件

使用webpack打包,src/index.html是入口文件,关于webpack的知识不多赘述。

文件列表

截图

index.html

<div id="loading">
  <div class="sk-chase">
    <div class="sk-chase-dot"></div>
    <div class="sk-chase-dot"></div>
    <div class="sk-chase-dot"></div>
    <div class="sk-chase-dot"></div>
    <div class="sk-chase-dot"></div>
    <div class="sk-chase-dot"></div>
  </div>
  <div>加载资源中...</div>
</div>
<div id="html2canvas" class="css3d-wapper">
  <div class="fire-div"></div>
</div>
<div id="earth-canvas"></div>

#loading: 加载中的loading效果

#earth-canvas:将canvas绘制到此dom下面

#html2canvas:将html转换成图片,显示地球标点

index.ts

webpack 会将此文件打包成js,放进 index.html 中

import World  from './world/Word'

// earth-canvas
const dom: HTMLElement = document.querySelector('#earth-canvas')
new World({
  dom,
})

new World() 将dom传进去。

World.ts 创建3D世界

new Basic(dom): 传入dom,创建出threejs场景、渲染器、相机和控制器。

this.basic = new Basic(option.dom)
this.scene = this.basic.scene
this.renderer = this.basic.renderer
this.controls = this.basic.controls
this.camera = this.basic.camera

new Sizes(dom):传入dom,主要进行dom尺寸计算和管理resize事件。

this.sizes = new Sizes({ dom: option.dom })

this.sizes.$on('resize', () => {
  this.renderer.setSize(Number(this.sizes.viewport.width), Number(this.sizes.viewport.height))
  this.camera.aspect = Number(this.sizes.viewport.width) / Number(this.sizes.viewport.height)
  this.camera.updateProjectionMatrix()
})

new Resources(function):传一个function,资源加载完成后会执行此function。

this.resources = new Resources(async () => {
  await this.createEarth()
  // 开始渲染
  this.render()
})

new Earth(options):地球相关配置


type options = {
  data: {
    startArray: {
      name: string,
      E: number, // 经度
      N: number, // 维度
    },
    endArray: {
      name: string,
      E: number, // 经度
      N: number, // 维度
    }[]
  }[]
  dom: HTMLElement,
  textures: Record<string, Texture>, // 贴图
  earth: {
    radius: number, // 地球半径
    rotateSpeed: number, // 地球旋转速度
    isRotation: boolean // 地球组是否自转
  }
  satellite: {
    show: boolean, // 是否显示卫星
    rotateSpeed: number, // 旋转速度
    size: number, // 卫星大小
    number: number, // 一个圆环几个球
  },
  punctuation: punctuation,
  flyLine: {
    color: number, // 飞线的颜色
    speed: number, // 飞机拖尾线速度
    flyLineColor: number // 飞行线的颜色
  },
}
  1. 将earth中的group添加到场景中

  2. 通过await init创建地球及其相关内容,因为创建一些东西需要时间,所以返回一个Promise

  3. 地球创建完之后隐藏dom,添加一个事先定义好的类名,使用animation渐渐隐藏掉dom

this.scene.add(this.earth.group)

await this.earth.init()

// 隐藏dom
const loading = document.querySelector('#loading')
loading.classList.add('out')

render():循环渲染

加载资源

地球中需要若干个贴图,在创建地球前,先把贴图加载进来。

Assets.ts 整理资源文件

/**
 * 资源文件
 * 把模型和图片分开进行加载
 */

interface ITextures {
  name: string
  url: string
}

export interface IResources {
  textures?: ITextures[],
}

const filePath = './images/earth/'
const fileSuffix = [
  'gradient',
  'redCircle',
  "label",
  "aperture",
  'earth_aperture',
  'light_column',
  'aircraft'
]

const textures = fileSuffix.map(item => {
  return {
    name: item,
    url: filePath + item + '.png'
  }
})

textures.push({
  name: 'earth',
  url: filePath + 'earth.jpg'
})

const resources: IResources = {
  textures
}

export {
  resources
}

我们把需要加载的资源文件全部列在这里,然后导出去。

Resources.ts 加载资源文件

/**
 * 资源管理和加载
 */
import { LoadingManager, Texture, TextureLoader } from 'three';
import { resources } from './Assets'
export class Resources {
  private manager: LoadingManager
  private callback: () => void;
  private textureLoader!: InstanceType<typeof TextureLoader>;
  public textures: Record<string, Texture>;
  constructor(callback: () => void) {
    this.callback = callback // 资源加载完成的回调

    this.textures = {} // 贴图对象

    this.setLoadingManager()
    this.loadResources()
  }

  /**
   * 管理加载状态
   */
  private setLoadingManager() {

    this.manager = new LoadingManager()
    // 开始加载
    this.manager.onStart = () => {
      console.log('开始加载资源文件')
    }
    // 加载完成
    this.manager.onLoad = () => {
      this.callback()
    }
    // 正在进行中
    this.manager.onProgress = (url) => {
      console.log(`正在加载:${url}`)
    }

    this.manager.onError = url => {
      console.log('加载失败:' + url)
    }

  }

  /**
   * 加载资源
   */
  private loadResources(): void {
    this.textureLoader = new TextureLoader(this.manager)
    resources.textures?.forEach((item) => {
      this.textureLoader.load(item.url, (t) => {
        this.textures[item.name] = t
      })
    })
  }
}

通过使用threejs提供的LoadingManager方法,管理资源的加载进度,以及保存一个textures对象,key为name,value为Texture对象。

接下来,我们要去创建我们的主角了~

添加地球 createEarth()

截图 截图

earth:创建一个地球mesh,并赋予ShaderMaterial材质和贴上地球贴图,之后可以通过着色器动画实现地球扫光效果。

points:创建一个由points组成的包围球,放在外围。

const earth_geometry = new SphereBufferGeometry(
  this.options.earth.radius,
  50,
  50
);

const earth_border = new SphereBufferGeometry(
  this.options.earth.radius + 10,
  60,
  60
);

const pointMaterial = new PointsMaterial({
  color: 0x81ffff, //设置颜色,默认 0xFFFFFF
  transparent: true,
  sizeAttenuation: true,
  opacity: 0.1,
  vertexColors: false, //定义材料是否使用顶点颜色,默认false ---如果该选项设置为true,则color属性失效
  size: 0.01, //定义粒子的大小。默认为1.0
})
const points = new Points(earth_border, pointMaterial); //将模型添加到场景

this.earthGroup.add(points);

this.options.textures.earth.wrapS = this.options.textures.earth.wrapT =
  RepeatWrapping;
this.uniforms.map.value = this.options.textures.earth;

const earth_material = new ShaderMaterial({
  // wireframe:true, // 显示模型线条
  uniforms: this.uniforms,
  vertexShader: earthVertex,
  fragmentShader: earthFragment,
});

earth_material.needsUpdate = true;
this.earth = new Mesh(earth_geometry, earth_material);
this.earth.name = "earth";
this.earthGroup.add(this.earth);

添加星星 createStars()

截图

星空背景其核心主要是创建了500个随机分布的点位

 for (let i = 0; i < 500; i++) {
    const vertex = new Vector3();
    vertex.x = 800 * Math.random() - 300;
    vertex.y = 800 * Math.random() - 300;
    vertex.z = 800 * Math.random() - 300;
    vertices.push(vertex.x, vertex.y, vertex.z);
    colors.push(new Color(1, 1, 1));
  }

使用点材质,贴上图片

const aroundMaterial = new PointsMaterial({
    size: 2,
    sizeAttenuation: true, // 尺寸衰减
    color: 0x4d76cf,
    transparent: true,
    opacity: 1,
    map: this.options.textures.gradient,
  });

添加地球辉光 createEarthGlow()

截图

地球边缘发光的效果,创建一个比地球大一点点的精灵片,贴上下图,而且精灵片是一直朝向摄像机的。

截图

添加地球辉光大气层 createEarthAperture()

截图

地球上的标点

添加柱状点位 createMarkupPoint()

高德地图取坐标点

我们需要将threejs的物体放置在地球上,就需要将经纬度转球面坐标,这是有详细转换文档

lon2xyz()我们直接用这个方法会把经纬度转成为球面坐标,拿到坐标我们就可以在对应的位置上创建物体

/**
 * 经纬度坐标转球面坐标  
 * @param {地球半径} R  
 * @param {经度(角度值)} longitude 
 * @param {维度(角度值)} latitude
 */
export const lon2xyz = (R:number, longitude:number, latitude:number): Vector3 => {
  let lon = longitude * Math.PI / 180; // 转弧度值
  const lat = latitude * Math.PI / 180; // 转弧度值
  lon = -lon; // js坐标系z坐标轴对应经度-90度,而不是90度

  // 经纬度坐标转球面坐标计算公式
  const x = R * Math.cos(lat) * Math.cos(lon);
  const y = R * Math.sin(lat);
  const z = R * Math.cos(lat) * Math.sin(lon);
  // 返回球面坐标
  return new Vector3(x, y, z);
}

这个白色和红色的柱子其实是两个mesh相交,并贴上贴图,通过转换过来的坐标放置在地球上。

截图

还有一个底座的效果

截图
截图

底座动画

// render
if (this.waveMeshArr.length) {
  this.waveMeshArr.forEach((mesh: Mesh) => {
    mesh.userData['scale'] += 0.007;
    mesh.scale.set(
      mesh.userData['size'] * mesh.userData['scale'],
      mesh.userData['size'] * mesh.userData['scale'],
      mesh.userData['size'] * mesh.userData['scale']
    );
    if (mesh.userData['scale'] <= 1.5) {
      (mesh.material as Material).opacity = (mesh.userData['scale'] - 1) * 2; //2等于1/(1.5-1.0),保证透明度在0~1之间变化
    } else if (mesh.userData['scale'] > 1.5 && mesh.userData['scale'] <= 2) {
      (mesh.material as Material).opacity = 1 - (mesh.userData['scale'] - 1.5) * 2; //2等于1/(2.0-1.5) mesh缩放2倍对应0 缩放1.5被对应1
    } else {
      mesh.userData['scale'] = 1;
    }
  });
}

循环waveMeshArr组,让里面的mesh变大并且渐渐消失,之后一直重复。

添加城市标签 createSpriteLabel()

截图

使用 html2canvas 创建精灵片标签

<div id="html2canvas" class="css3d-wapper">
  <div class="fire-div"></div>
</div>
const opts = {
  backgroundColor: null, // 背景透明
  scale: 6,
  dpi: window.devicePixelRatio,
};
const canvas = await html2canvas(document.getElementById("html2canvas"), opts)
const dataURL = canvas.toDataURL("image/png");
const map = new TextureLoader().load(dataURL);
const material = new SpriteMaterial({
  map: map,
  transparent: true,
});
const sprite = new Sprite(material);
const len = 5 + (e.name.length - 2) * 2;
sprite.scale.set(len, 3, 1);
sprite.position.set(p.x * 1.1, p.y * 1.1, p.z * 1.1);
this.earth.add(sprite);

地球上的标签是通过html2canvas插件将html转换成贴图,贴到精灵片上。

创建环绕卫星 createAnimateCircle()

截图

getCirclePoints获取一个圆环坐标点列表

createAnimateLine通过圆环点位创建一个圆环线,并clone出3条,加上若干个小卫星

通过每帧循环让线条转圈,并带动小卫星转

// render()
this.circleLineList.forEach((e) => {
  e.rotateY(this.options.satellite.rotateSpeed);
});

End

感谢观看,如果有什么意见或者疑问请随时联系~