three.js 3D地球

4,070 阅读5分钟

效果图:

微信截图_20210312164033.png 预览地址:http://192.144.225.99/earth

实现过程

一、准备工作

渲染地球所需的图片资料,大部分都来自于参考网站

地区模型:使用blender以及blender、blender-osm

  1. 获取某个地区的模型数据(包含地形、路网、建筑)、卫星图。生成总模型。
  2. 获取到这个地区的边界图(我这里是用SVG)。
  3. 通过SVG生成一个边界模型。
  4. 两个模型作布尔操作,切出想要的轮廓。
  5. 导出模型。 这样就有一个地区的模型了,在生成模型的时候,可以对模型内的路网、建筑等做拆分、取名等操作。
    这个模型在例子中就只是显示一下,主要是想记录下制作过程。

微信截图_20210312164221.png

二、开发

项目环境准备

习惯使用vue,所以就用vue-cli搭建的项目,本示例项目跟项目环境关系不大,主要关注src/earth/index.ts文件即可。

基本场景

three.js基本场景相机、渲染器、scene
这里使用了两个渲染器,WebGLRendererCSS3DRenderer,因为要渲染文本,又不想多引入字体文件,所以使用dom的形式来渲染,就多出一个CSS3DRenderer 基本场景代码如下:

// 主场景
const scene = new Scene();
// 文本所在场景
const css3Scene = new Scene();
// 摄像机
const camera = new PerspectiveCamera(
  ...[
    45, // fov — 摄像机视锥体垂直视野角度
    1, // aspect — 摄像机视锥体长宽比
    0.01, // near — 摄像机视锥体近端面
    1000, // far — 摄像机视锥体远端面
  ]
);
// 主渲染器
const renderer = new WebGLRenderer({ alpha: true, antialias: true });
// 文本所在渲染器
const css3Renderer = new CSS3DRenderer();
// 加入鼠标控制
const controls = new OrbitControls(camera, renderer.domElement);
let mountDom!: HTMLElement;
const size = {
  width: 0,
  height: 0,
};
const offset = {
  x: 0,
  y: 0,
};
export const getDomSize = (currentDOM: HTMLElement) => {
  return currentDOM.getBoundingClientRect();
};
const localGetDomSize = (mountDom: HTMLElement) => {
  const rect = getDomSize(mountDom);
  size.width = rect.width;
  size.height = rect.height;
  offset.x = rect.x;
  offset.y = rect.y;
  return rect;
};
// 适应window大小
export const onWindowResize = () => {
  const { width, height } = localGetDomSize(mountDom);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setSize(width, height);
  css3Renderer.setSize(width, height);
};
const listen = () => {
  window.addEventListener("resize", onWindowResize, false);
};
//渲染循环
const animate = () => {
  requestAnimationFrame(animate);
  // 动画更新
  (TWEEN as any).update();
  // 这里加了后处理,相当于renderer.render();
  composer.render();
  
  css3Renderer.render(scene, camera);
};
// 控制的最小距离与最大距离
export const controlSetting = {
  minDistance: 1,
  maxDistance: 160,
};
// 光照
const initLight = () => {
  const group = new Group();
  // lights
  group.add(new AmbientLight(0x666666));
  const light = new DirectionalLight(0xffffff, 1);
  light.position.set(0, 1, 0);
  group.add(light);
  scene.add(group);
};
// 相机起始位置x: -8,
// y: 16,
// z: -24,
export const cameraInitPosition = new Vector3(-8, 16, -24).multiplyScalar(0.1);
// 开始函数
const init = (initDom: HTMLElement) => {
  mountDom = initDom;
  const { width, height } = localGetDomSize(mountDom);
  camera.aspect = width / height;
  camera.updateProjectionMatrix();
  renderer.setPixelRatio(window.devicePixelRatio);
  renderer.setSize(width, height);
  css3Renderer.setSize(width, height);
  mountDom.appendChild(renderer.domElement);
  const cssDom = css3Renderer.domElement;
  cssDom.classList.add("css3Renderer");
  mountDom.appendChild(cssDom);
  controls.minDistance = controlSetting.minDistance;
  controls.maxDistance = controlSetting.maxDistance;
  camera.position.copy(cameraInitPosition);
  camera.lookAt(0, 0, 0);
  listen();
  initLight();
  animate();
  // 加入参考轴
  const help = new AxesHelper(20);
  scene.add(help);
};

主要物体部分

场景中的物体主要包含:地球、云层、城市点、点之间的连线、卫星轨迹

地球: 地球就是一个圆球的物体,这里使用three.js自带的球体。

 // 内层球(地球本身),球体生成
  const geo = new SphereBufferGeometry(10, 100, 100);
  const mesh = new Mesh(geo, shaderMaterial);

主要是材质部分的片元着色器代码,先加载三个素材图片。
这里只需要lineTexturefillTexture,mapTexture就是一个点的图片(本想还原参考网站的效果,但由于效果太丑,就没用它)。
uniforms传递到片元着色器。使用lineTexture画出大陆的轮廓线,使用fillTexture填充大陆。
主要代码如下:

const lineTexture = new TextureLoader().load("./imgs/merge_from_ofoct.jpg");
const fillTexture = new TextureLoader().load("./imgs/earth_1.png");
const mapTexture = new TextureLoader().load("./imgs/dot.png");
const uniforms = {
  lineTexture: { value: lineTexture },
  fillTexture: { value: fillTexture },
  mapTexture: { value: mapTexture },
};
// 内层球材质
const shaderMaterial = new ShaderMaterial({
  uniforms: uniforms,
  side: DoubleSide,
  vertexShader: `
      precision highp float;
      varying vec2 vUv;
      varying vec3 vNormal;
      varying float _alpha;
      void main() {
        vUv = uv;
        vNormal = normalize(normalMatrix * normal);
        vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
        gl_Position = projectionMatrix * mvPosition;
      }
      `,
  fragmentShader: `
      uniform sampler2D lineTexture;
      uniform sampler2D fillTexture;
      uniform sampler2D mapTexture;
      varying vec2 vUv;
      varying vec3 vNormal;
      varying float _alpha;
      void main() {
        vec4 lineColor = texture2D( lineTexture, vUv );
        vec4 fillColor = texture2D( fillTexture, vUv );
        // 由于我们希望得到一个球的两边亮一些的效果,
        // 就得借助球表面的向量在Z轴上的投影的大小来达到变化颜色的效果
        // vNormal代表每个垂直于球平面的向量,再点乘Z轴,因为摄像头是从Z向里看的,
        // 所以这里我们取(0.0, 0.0, 1.0),Z轴
        float silhouette = dot(vec3(0.0, 0.0, 1.0) ,vNormal );
        lineColor = vec4(lineColor.rgb,1.0);
        float z = gl_FragCoord.z;
        if(lineColor.r <= 0.1) {
          if(fillColor.r <= 0.1) {
            float x = sin(vUv.x * 1000.0) * 0.5 + 0.5;
            float y = sin(vUv.y * 1000.0) * 0.5 + 0.5;
            vec4 mapColor = texture2D( mapTexture, vec2(x, y) );
            // 球面变化关联到颜色
             float c = pow(1.0 - abs(silhouette), 1.0);
             if(c < 0.2) {
               c = 0.2;
             }
             // lineColor = vec4(c,c,c, 1.0) * mapColor.rgb;
             lineColor = vec4(c,c,c, 1.0);
          } else {
             discard;
          }
        }
        gl_FragColor = vec4(lineColor.rgb * vec3(0.0,1.0,167.0 / 255.0), 1.0);
      }
  `,
  transparent: true,
});

由于我们希望得到一个球的两边亮一些的效果,就得借助球表面的向量在Z轴(因为摄像头是从Z向里看的,所以这里我们取(0.0, 0.0, 1.0),Z轴)上的投影的大小来达到变化颜色,这里的vNormal代表每个垂直于球平面的向量,再点乘Z轴,就得到了一个随球面变化的值,再将这个值关联到显示的颜色,就达到了效果。

只有大陆轮廓的样子:

微信截图_20210312164240.png 可以将源代码中shaderMaterial的片元着色器参数的main方法调整为下面这样,得到一个只有大陆轮廓的地球。

void main() {
        vec4 lineColor = texture2D( lineTexture, vUv );
        vec4 fillColor = texture2D( fillTexture, vUv );
        float silhouette = dot(vec3(0.0, 0.0, 1.0) ,vNormal );
        lineColor = vec4(lineColor.rgb,1.0);
        float z = gl_FragCoord.z;
        if(lineColor.r <= 0.1) {
          // 舍弃
          discard;
          // if(fillColor.r <= 0.1) {
          //   float c = pow(1.0 - abs(silhouette), 1.0);
          //   if(c < 0.2) {
          //     c = 0.2;
          //   }
          //   lineColor = vec4(c,c,c, 1.0);
          // } else {
          //    discard;
          // }
        } else {
          float c = pow(1.0 - abs(silhouette), 1.0);
          lineColor = vec4(c,c,c, 1.0);
        }
        gl_FragColor = vec4(lineColor.rgb * vec3(0.0,1.0,167.0 / 255.0), 1.0);
      }

实现原理:根据UV获取出lineTexture(就是下面这个图)上某一点的颜色,由于只有黑有白,就直接判断rgb其中一个就行。当这个点的r值小于等于0.1时,就舍弃;大于0.1时,就显示颜色,通过这种方式就获取到了下图中的白色轮廓。

微信截图_20210312164300.png

包含轮廓与大陆填充的地球:

微信截图_20210312164312.png 跟上面画轮廓类似的,使用下面带填充的大陆图,在轮廓图要舍弃像素点的时候,判断是fillColor.r是否小于等于0.1,就是轮廓图黑色的部分与下图黑色的部分的交集,则显示颜色;否则,就舍弃。

微信截图_20210312164322.png 云层:
云层与地球的实现方式类似,也是一个球上面贴图,通过图片的颜色判断是否应该显示颜色,通过球面上的法向量判断显示颜色的深浅。

城市点
先通过百度地图等工具找到区域的经纬度,再对经纬度做一下转换(网上找的小函数,再稍微修改下)。
生成圆柱体,设置圆柱体位置(通过经纬度转换而来),同时生成城市名文本。

// 基础经纬度信息
export const locationDataList: {
  x: number;
  y: number;
  name: string;
  position?: Vector3;
  sprite?: CSS3DSprite;
}[] = [
  {
    x: 117.079938,
    y: 36.779526,
    name: "北京",
  },
  {
    x: 113.304199,
    y: 23.147831,
    name: "广州",
  },
  {
    x: 121.462143,
    y: 31.222836,
    name: "上海",
  },
  {
    x: 117.211019,
    y: 31.83075,
    name: "合肥",
  },
  {
    x: 120.262604,
    y: 30.473382,
    name: "杭州",
  },
];
function lglt2v({ x: lng, y: lat }: { x: number; y: number }, radius: number) {
  // 有点偏差,手动校对的
  lng = lng - 12;
  lat = lat - 3;
  const phi = (180 + lng) * (Math.PI / 180);
  const theta = (90 - lat) * (Math.PI / 180);
  return new Vector3(
    -radius * Math.sin(theta) * Math.cos(phi),
    radius * Math.cos(theta),
    radius * Math.sin(theta) * Math.sin(phi)
  );
}
const generateLocals = () => {
  const geo = new CircleBufferGeometry(0.08, 10);
  const mater = new MeshBasicMaterial({ color: "red", side: DoubleSide });
  // 模板物体
  const temp = new Object3D();
  // 城市点物体(包含所有城市点)
  Locations = new InstancedMesh(geo, mater, locationDataList.length);
  locationDataList.forEach((item, index) => {
    // 根据经纬度获取位置
    const v = lglt2v(item, 10);
    // 改变模板物体位置、旋转角,再获取它的矩阵(包含位置、缩放、旋转信息)
    temp.position.copy(v);
    // 将模板物体面向原点(地球圆心)
    temp.lookAt(new Vector3());
    temp.updateMatrix();
    const m = temp.matrix;
    // 将每个城市点的矩阵设置到InstancedMesh中
    Locations?.setMatrixAt(index, m);
    item.position = v;
    // 设置城市名文本文字
    const element = document.createElement("div");
    element.className = "element";
    element.innerHTML = item.name;
    const object = new CSS3DSprite(element);
    object.scale.set(0.01, 0.01, 0.01);
    object.position.copy(v.clone().multiplyScalar(1.1));
    object.visible = false;
    item.sprite = object;
    App.text.add(object);
    App.text.visible = false;
    scene.add(App.text);
  });
  return Locations;
};

点之间的连线

const connectLine = (v0: Vector3, v1: Vector3) => {
  // 向量v0与向量v1之间的3个向量
  const midV = v0.clone().lerp(v1.clone(), 0.25);
  const midV1 = v0.clone().lerp(v1.clone(), 0.5);
  const midV2 = v0.clone().lerp(v1.clone(), 0.75);
  const distance = v0.clone().distanceTo(v1.clone());
  // 将这3个向量变长一些,数字10是地球的半径
  midV.normalize().multiplyScalar(10 + distance / 8);
  midV1.normalize().multiplyScalar(10 + distance / 6);
  midV2.normalize().multiplyScalar(10 + distance / 8);
  // 通过v0、v1、与它们之间的3个向量生成一条比较平滑的线
  const spline = new CatmullRomCurve3(
    [v0, midV, midV1, midV2, v1],
    false,
    "chordal",
    0.5
  );
  // 取线上的30个点,生成meshline
  const positions = spline.getPoints(30);
  const geometry = new Geometry();
  geometry.vertices.push(...positions);
  const meshLine = new MeshLine();
  meshLine.setGeometry(geometry);
  const resolution = new Vector2(window.innerWidth, window.innerHeight);
  const material = new MeshLineMaterial({
    useMap: false,
    color: new Color("red"),
    opacity: 1,
    resolution: resolution,
    dashArray: 0.0,
    dashRatio: 0,
    dashOffset: 0,
    sizeAttenuation: true,
    lineWidth: 0.02,
    transparent: true,
    near: camera.near,
    far: camera.far,
  });
  const line = new Mesh(meshLine.geometry, material);
  App.earth.add(line);
};

卫星轨迹
生成3个线圈,再让其以不同的角度、速度旋转。通过深度(gl_FragCoord.z / gl_FragCoord.w可以得到当前片元和camera之间的距离),来改变颜色深浅。

const SatelliteLineShaderMaterial = new ShaderMaterial({
  side: DoubleSide,
  vertexShader: `
      precision highp float;
      void main() {
        vec4 mvPosition = modelViewMatrix * vec4( position, 1.0 );
        gl_Position = projectionMatrix * mvPosition;
      }
      `,
  fragmentShader: `
      void main() {
      float length = 1.0 - (gl_FragCoord.z / gl_FragCoord.w) / 40.0;
      if(length > 1.0) {
        length = 1.0;
      } else if(length < 0.3) {
        length = 0.3;
      }
      gl_FragColor = vec4(vec3(0.0,1.0,167.0 / 255.0) * length, length);
  }
`,
  transparent: true,
});

源码地址:github.com/snhwv/earth…
这个例子的主要实现就讲完啦~