Threejs 地圖 3D 可視化

作者:Defineee

https://juejin.cn/post/7247027696822304827

前言

threejs 小練習,從頭實現如何加載地理數據,並將其映射到三維場景中的對象上。

獲取數據

在開始繪製圖形前,需要一份包含地理信息數據,我們可以從阿里雲提供的小工具獲取:http://datav.aliyun.com/portal/school/atlas/area_selector

在範圍選擇器中,可以選擇整個或者各個省份的地理信息數據。

生成圖形

獲取數據後,先分析一下 JSON 的結構

properties 中包含了名字、中心、質心等信息, geometry.coordinates 則是地理的座標點,我們需要做的是將這些點連成線。

THREE.Shpae

const createMap = (data) ={
  const map = new THREE.Object3D();
   data.features.forEach((feature) ={
   const unit = new THREE.Object3D();
   const { coordinates, type } = feature.geometry;
   
    coordinates.forEach((coordinate) ={
    
      if (type === "MultiPolygon") coordinate.forEach((item) => fn(item));
      if (type === "Polygon") fn(coordinate);
      
      function fn(coordinate) {
        const mesh = createMesh(coordinate);
        unit.add(mesh);
      }
    });
     map.add(unit);
  });
  return map;
};

這裏需要注意在 geometry 中的 type 分爲 MultiPolygonPolygon,需要分別處理,不然會造成個別區域缺失, 二者區別是 MultiPolygon 的座標多一層嵌套數據,所以這裏多做一次遍歷。

const createMesh = (data, color, depth) ={
  const shape = new THREE.Shape();
  data.forEach((item, idx) ={
    cosnt [x,y] =item
    if (idx === 0) shape.moveTo(x, -y);
    else shape.lineTo(x, -y);
  });
  
  const shapeGeometry = new THREE.ShapeGeometry(shape);
  const shapematerial = new THREE.MeshStandardMaterial({
    color: 0xfff,
    side: THREE.DoubleSide
  });

  const mesh = new THREE.Mesh(shapeGeometry, shapematerial);
  return mesh;
};

通過 THREE.Shape 繪製一個二維的形狀平面後, 但是打開網頁後會發現頁面中並沒有出現圖形,這是因爲是 json 中的座標非常大,在縮小後才能勉強看到,所以我們需要對座標進行相應的處理。

座標矯正 1

這裏先介紹第一種矯正的方法

import * as d3 from "d3";
...
const offsetXY = d3.geoMercator();

在 createMap 中新增獲取第一個子數據的 centroid 以及偏移代碼,這裏的 centroid 也就是杭州的質心。

d3.geoMercator() 是一個地理投影函數,用於將地球表面的經緯度座標映射到二維平面上。

在代碼中,.center(center) 是用於指定投影的中心點,這個中心點決定了投影的中心位置,地圖上的所有要素都將以該點爲中心進行投影轉換。

.translate([0, 0]) 是指定投影的平移量。這裏的 [0, 0] 表示在平面座標系中的 x 和 y 方向上都沒有平移,也就是將地圖的投影結果放置在平面座標系的原點位置。

這份數據是浙江省的地理信息,所以根據以上代碼,圖形的中心點已經以到杭州的質心上,並且座標爲 [0,0]

THREE.ExtrudeGeometry

接着再通過 THREE.ExtrudeGeometry 將 shape 從二維擠出成三維。爲了方便查看剛纔代碼使用了new THREE.ShapeGeometry(shape);我們替換成 ExtrudeGeometry

  const shapeGeometry = new THREE.ExtrudeGeometry(shape, {
    depth: 1,
    bevelEnabled: false,
  });

depth:圖形擠出的深度,默認值爲 1

bevelEnabled:對擠出的形狀應用是否斜角,默認值爲 true

區域劃分

現在的圖形全都是一個顏色,看不出區域

    const color = new THREE.Color(`hsl(
      ${233},
      ${Math.random() * 30 + 55}%,
      ${Math.random() * 30 + 55}%)`).getHex();
    const depth = Math.random() * 0.3 + 0.3;
    ...
    ...
    const mesh = createMesh(coordinate, color, depth);

我們寫一個隨機顏色和隨機的深度,在 data.features 中寫入,確保每一個子區域一個顏色,如果在 createMesh 中實現會產生以下區別,舟山、寧波、溫州的島嶼會產生不同的顏色。

繪製描邊

繪製描邊的方法和之前的 shape 有所不同

創建一個 THREE.BufferGeometry 對象,並通過一組給定的點來設置其幾何形狀,再通過 LineBasicMaterial 材質渲染基本的線條

const createLine = (data, depth) ={
  const points = [];
  data.forEach((item) ={
    const [x, y] = offsetXY(item);
    points.push(new THREE.Vector3(x, -y, 0));
  });
  const lineGeometry = new THREE.BufferGeometry().setFromPoints(points);
  const uplineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
  const downlineMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });

  const upLine = new THREE.Line(lineGeometry, uplineMaterial);
  const downLine = new THREE.Line(lineGeometry, downlineMaterial);
  downLine.position.z = -0.0001;
  upLine.position.z = depth + 0.0001;
  return [upLine, downLine];
};

繪製標籤信息

接下來我們通過 css2d 的方式向圖形中添加城市名稱

使用 css2d 需要相應的引用以及設置

import {
  CSS2DRenderer,
  CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";
    ...
    ...
  const labelRenderer = new CSS2DRenderer();
  labelRenderer.domElement.style.position = "absolute";
  labelRenderer.domElement.style.top = "0px";
  labelRenderer.domElement.style.pointerEvents = "none";
  labelRenderer.setSize(window.innerWidth, window.innerHeight);
  document.getElementById("map").appendChild(labelRenderer.domElement);

除了能使用 css 的樣式,通過 new CSS2DObject() 這一步後可以操作 threejs 元素一樣操作 div,其實原理是仍是使用 transform 屬性進行 3d 變換操作。

const createLabel = (name, point, depth) ={
  const div = document.createElement("div");
  div.style.color = "#fff";
  div.style.fontSize = "12px";
  div.style.textShadow = "1px 1px 2px #047cd6";
  div.textContent = name;
  const label = new CSS2DObject(div);
  label.scale.set(0.01, 0.01, 0.01);
  const [x, y] = offsetXY(point);
  label.position.set(x, -y, depth);
  return label;
};

繪製圖標

繪製圖標也可以使用 css2d 的方式,但是除了 css2d,我們還有多種方式:css3d,svg,Sprite。這裏我們使用 Sprite。

const createIcon = (point, depth) ={
  const url = new URL("../assets/icon.png", import.meta.url).href;
  const map = new THREE.TextureLoader().load(url);
  const material = new THREE.SpriteMaterial({
    map: map,
    transparent: true,
  });
  const sprite = new THREE.Sprite(material);
  const [x, y] = offsetXY(point);
  sprite.scale.set(0.3, 0.3, 0.3);

  sprite.position.set(x, -y, depth + 0.2);
  sprite.renderOrder = 1;

  return sprite;
};

SPrite 是一個總是面朝着攝像機的平面,這一點似乎和 css2d 的效果一樣,不過二者還略有不同。

圖中我們可以看到,SPrite 會隨着相機的距離而改變大小。

座標矯正 2

之前的座標矯正我們可以將中心移到某個點上,那如果想把中心移到整個圖形的中心該如何實現?通過已有的數據我們只能將中心移到某個區域的中心或者質心,並不知道圖形的中心在哪裏,當然我們可以手動調試,不過換一份地理數據又的重新調試。

對此,我們可以使用 threejs 中的包圍盒

      const box = new THREE.Box3().setFromObject(map);
      const boxHelper = new THREE.Box3Helper(box, 0xffff00);
      scene.add(boxHelper);

創建一個Box3對象,並通過調用setFromObject(map)方法,將map的包圍盒信息存儲在box變量中。,box變量現在包含了map對象的邊界範圍。爲了便於觀察再加一個輔助器。

接着通過const center = box.getCenter(new THREE.Vector3());獲取包圍盒的中心點座標。

  map.position.x = map.position.x - center.x ;
  map.position.y = map.position.y - center.y ;

對中心點進行計算後便是一個相對中心的位置,因爲有的地形涉及島嶼海域或者形狀不太規整,得出的中心點可能不是理想效果。

鼠標交互

最後我們來實現圖形與鼠標的交互, THREE.Raycaster 可以從指定的原點(起點)沿着指定的方向(射線)發射一條射線。這條射線可以與場景中的對象進行相交檢測,以確定射線是否與對象相交,從而獲取與射線相交的對象或交點信息,常用於用戶交互、拾取物體、碰撞檢測等場景。

        const mouse = new THREE.Vector2();
        
        //將鼠標位置歸一化爲設備座標。x 和 y 方向的取值範圍是 (-1 to +1)
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
        
        const raycaster = new THREE.Raycaster();
        
        // 通過攝像機和鼠標位置更新射線
        raycaster.setFromCamera(mouse, camera);
        
        // 計算物體和射線的焦點
        const intersects = raycaster.intersectObjects(map.children)

通過以上代碼我們可以在 intersects 裏獲取到鼠標都觸發了哪些對象。

可以看到我們觸發很多對象,但是大部分 type 都是 Line,也就是之前繪製的描邊,這些線段會干擾到正常的點擊,所以我們要將它過濾掉。

        const intersects = raycaster
          .intersectObjects(map.children)
          .filter((item) => item.object.type !== "Line");

這裏簡單處理一下,點擊 Mesh 使其透明,點擊 Sprite 打印對象。

        if (intersects.length > 0) {
          if (intersects[0].object.type === "Mesh") {
            if (intersect) isAplha(intersect, 1);
            intersect = intersects[0].object.parent;
            isAplha(intersect, 0.4);
          }
          if (intersects[0].object.type === "Sprite") {
            console.log(intersects[0].object);
          }
        } else {
          if (intersect) isAplha(intersect, 1);
        }
        function isAplha(intersect, opacity) {
          intersect.children.forEach((item) ={
            if (item.type === "Mesh") {
              item.material.opacity = opacity;
            }
          });
        }

有一點需要注意在獲取 Mesh 對象時,我們使用的是intersects[0].object.parent;,拿到了觸發對象的的父級對象。以舟山爲例,我們點擊了其中一個島嶼,但是想要整個區域都發生變化,所以需要獲取父級對象再遍歷處理。

其他設置

大致的功能都實現完成了,我們還可以在視覺上增加一些風格。

  const ambientLight = new THREE.AmbientLight(0xd4e7fd, 4);
  scene.add(ambientLight);
  const directionalLight = new THREE.DirectionalLight(0xe8eaeb, 0.2);
  directionalLight.position.set(0, 10, 5);
  const directionalLight2 = directionalLight.clone();
  directionalLight2.position.set(0, 10, -5);
  const directionalLight3 = directionalLight.clone();
  directionalLight3.position.set(5, 10, 0);
  const directionalLight4 = directionalLight.clone();
  directionalLight4.position.set(-5, 10, 0);
  scene.add(directionalLight);
  scene.add(directionalLight2);
  scene.add(directionalLight3);
  scene.add(directionalLight4);
  
  ...
  ...
  
  THREE.MeshStandardMaterial({
    color: color,
    emissive: 0x000000,
    roughness: 0.45,
    metalness: 0.8,
    transparent: true,
    side: THREE.DoubleSide,
  });

配合燈光以及 MeshStandardMaterial 材質實現反光效果。

結尾

github:https://github.com/1023byte/3Dmap

推薦閱讀  點擊標題可跳轉

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/CM6eWmE0XLHKNqVG70HQlA