【Three.js实战练习-1】实现中国3D地图

265 阅读2分钟

需求分析

  • 使用经纬度数据绘制 wed3d 地图
  • hover 地图显示省份信息
  • 使用精灵图绘制报警点

需求实现

初始化项目

使用 vite 创建基本项目

yarn create vite

创建文件目录结构

image.png

相机

import { PerspectiveCamera } from "three";

// 创建相机
const createCamera = () => {
  const camera = new PerspectiveCamera(
    40,
    window.innerWidth / window.innerHeight,
    0.1,
    1000
  );

  camera.position.set(0, 0, 250);

  return camera;
};

export { createCamera };

控制器

import { PerspectiveCamera } from "three";

import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";

const createControl = (camera: PerspectiveCamera, canvas: HTMLElement) => {
  const controls = new OrbitControls(camera, canvas);

  controls.enableDamping = true;

  Object.assign(controls, {
    tick: controls.update,
  });

  return controls;
};

export { createControl };

场景

import { Color, Scene } from "three";

// 创建场景
const createScene = () => {
  const scene = new Scene();

  scene.background = new Color("#baccd9");

  return scene;
};

export { createScene };

渲染器

import { WebGLRenderer } from "three";

const createRenderer = () => {
  const renderer = new WebGLRenderer({
    antialias: true, //抗锯齿
  });
  return renderer;
};

export { createRenderer };

循环控制类

import { Camera, Clock, Scene, WebGLRenderer } from "three";

const clock = new Clock();

export interface UpdateItem {
  tick?: (delta: number) => void;
}

class Loop {
  public updateList: UpdateItem[];
  constructor(
    private camera: Camera,
    private scene: Scene,
    private renderer: WebGLRenderer
  ) {
    this.updateList = [];
  }

  start() {
    this.renderer.setAnimationLoop(() => {
      this.tick();

      this.renderer.render(this.scene, this.camera);
    });
  }

  stop() {
    this.renderer.setAnimationLoop(null);
  }

  tick() {
    const delta = clock.getDelta();

    for (const obj of this.updateList) {
      if (obj.tick) {
        obj.tick(delta);
      }
    }
  }
}

export { Loop };

自适应类

import { PerspectiveCamera, WebGLRenderer } from "three";

const setSize = (
  container: HTMLElement,
  camera: PerspectiveCamera,
  renderer: WebGLRenderer
) => {
  camera.aspect = container.clientWidth / container.clientHeight;
  camera.updateProjectionMatrix();

  renderer.setSize(container.clientWidth, container.clientHeight);
  renderer.setPixelRatio(window.devicePixelRatio);
};

class Resizer {
  constructor(
    container: HTMLElement,
    camera: PerspectiveCamera,
    renderer: WebGLRenderer
  ) {
    setSize(container, camera, renderer);
    window.addEventListener("resize", () => {
      setSize(container, camera, renderer);
    });
  }
}

export { Resizer };

地图核心代码

这里有个容易踩坑的地方:遍历经纬度数据的时候,一定要判断数据的 geometry type, type 是"MultiPolygon"的多嵌套了一层。

// 加载器
const loader = new TextureLoader();
// 加载报警点图片
const animateTexture = loader.load(cautionSprite);

type NumberArray = [number, number];

// 转换函数
export const projection = geoMercator()
  .center([104.065735, 30.659462]) // 设置中心点
  .translate([0, 0]);

// 创建mesh,line
const createShape = (points: NumberArray[]) => {
  const path: Vector2[] = [];

  points.forEach((item) => {
    const [x, y] = projection(item) as NumberArray;
    path.push(new Vector2(x, -y));
  });

  const shape = new Shape(path);

  const geometry = new ExtrudeGeometry(shape, {
    bevelEnabled: false, // -bevelEnabled — bool,对挤出的形状应用是否斜角,默认值为true。
    depth: 5,
  });
  const material = new MeshBasicMaterial({
    color: "#2e317c",
    opacity: 0.8,
    transparent: true,
  });
  const mesh = new Mesh(geometry, material);
  const linegeometry = new BufferGeometry().setFromPoints(path);
  const linematerial = new LineBasicMaterial({
    color: "#ffffff",
    linewidth: 3,
  });
  const line = new Line(linegeometry, linematerial);
  line.position.z = 6;

  return { mesh, line };
};

class Map {
  pointer: Vector2;
  private raycaster: Raycaster;
  currentMesh: Mesh[];
  constructor(
    protected readonly scene: Scene,
    protected readonly camera: Camera
  ) {
    this.pointer = new Vector2();
    this.raycaster = new Raycaster();
    this.currentMesh = [];

    window.addEventListener("pointermove", this.onPointerMove.bind(this));
  }

  private onPointerMove(event: PointerEvent) {
    // 将鼠标位置归一化为设备坐标。x 和 y 方向的取值范围是 (-1 to +1)
    this.pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
    this.pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;

    this.raycaster.setFromCamera(this.pointer, this.camera);

    if (this.currentMesh.length) {
      for (let index = 0; index < this.currentMesh.length; index++) {
        (this.currentMesh[index].material as MeshBasicMaterial).color.set(
          "#2e317c"
        );
      }
    }

    const intersects = this.raycaster.intersectObjects(this.scene.children);
    if (!intersects.length) { //判断是都有选中物体
      emitter.emit("hoverMap", false);
    }
    this.currentMesh = [];
    for (let index = 0; index < intersects.length; index++) {
      if (
        intersects[index].object.type === "Mesh" &&
        !intersects[index].object.userData.isAnimateMarker
      ) {
        const mesh = intersects[index].object as Mesh;
        emitter.emit("hoverMap", {
          x: event.clientX,
          y: event.clientY,
          data: mesh.userData,
        });
        this.currentMesh.push(mesh);
        (mesh.material as MeshBasicMaterial).color.set("#22a2c3");
      }
    }
  }

  create() {
    const objectGroup = new Object3D();
    MapData.features.forEach((item) => {
      const { coordinates, type } = item.geometry;
      const { properties } = item;
      if (["MultiPolygon"].includes(type)) {
        coordinates.forEach((v) => {
          v.forEach((j) => {
            const { mesh, line } = createShape(j as NumberArray[]);
            mesh.userData = properties;
            objectGroup.add(mesh, line);
          });
        });
      } else {
        coordinates.forEach((v) => {
          const { mesh, line } = createShape(v as NumberArray[]);
          mesh.userData = properties;
          objectGroup.add(mesh, line);
        });
      }
    });

    this.scene.add(objectGroup);
  }

  createAnimateMarker() {
    const animateMarker = new AnimateMarker(
      {
        texture: animateTexture,
        tilesHoriz: 23,
        tilesVert: 1,
        numTiles: 23,
        tileDispDuration: 75,
      },
      10,
      this.camera
    );

    return animateMarker;
  }
}

export { Map };

World 中组装

import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import { Map } from "./components/Map";
import { createCamera } from "./system/camera";
import { createControl } from "./system/control";
import { Loop, UpdateItem } from "./system/Loop";
import { createRenderer } from "./system/renderer";
import { Resizer } from "./system/Resizer";
import { createScene } from "./system/scene";

class World {
  protected readonly camera: PerspectiveCamera;
  protected readonly scene: Scene;
  protected readonly renderer: WebGLRenderer;
  protected readonly loop: Loop;
  protected readonly controls: OrbitControls;

  constructor(container: HTMLElement) {
    this.camera = createCamera();
    this.scene = createScene();
    this.renderer = createRenderer();
    this.controls = createControl(this.camera, this.renderer.domElement);
    this.loop = new Loop(this.camera, this.scene, this.renderer);

    this.loop.updateList.push(this.controls as UpdateItem);

    container.append(this.renderer.domElement);

    new Resizer(container, this.camera, this.renderer);
  }

  render() {
    // 渲染场景
    this.renderer.render(this.scene, this.camera);
  }

  init() {
    // 场景添加物体
    const map = new Map(this.scene, this.camera);
    map.create();

    const animateMarker = map.createAnimateMarker();
    this.loop.updateList.push(animateMarker.annie);
    this.scene.add(animateMarker.mesh);

    animateMarker.setPosition([116.405285, 39.904989], 10);
  }

  start() {
    this.loop.start();
  }
}

export { World };

App.vue 使用

<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { emitter } from './utils/mitt';
import { World } from './World';
import { projection } from "./World/components/Map"

const currentInfo = ref();
const infobox = ref<HTMLElement>()


const container = ref<HTMLElement>()
const main = () => {
  if (!container.value) return;
  const world = new World(container.value)
  world.init();
  world.start();
}

onMounted(() => {
  main();
  //监听地图hover事件
  emitter.on("hoverMap", (emitData) => {
    if (!emitData) {
      currentInfo.value = null;
      return;
    }
    const { x, y, data: res } = emitData as { x: number, y: number, data: { center: [number, number], name: string } }
    if (infobox.value) {
      infobox.value.style.left = `${x}px`
      infobox.value.style.top = `${y}px`
      currentInfo.value = res;
    }
  });
})
</script>

<template>
  <div ref="container" class="app"></div>
  <div ref="infobox" v-show="currentInfo && currentInfo?.name" class="g-info">{{ currentInfo?.name || "--" }}</div>
</template>

<style scoped>
.app {
  width: 100vw;
  height: 100vh;
  overflow: hidden;
}

.g-info {
  position: fixed;
  top: 0;
  left: 0;
  height: 32px;
  background-color: rgba(0, 0, 0, .5);
  border-radius: 2px;
  padding: 0 20px;
  color: #fff;
  line-height: 30px;
  text-align: center;
}
</style>