大神教你基於 vue3-threejs 實現可視化大屏

前言

Three.js 是一款基於原生 WebGL 封裝通用 Web 3D 引擎,在小遊戲、產品展示、物聯網、數字孿生、智慧城市園區、機械、建築、全景看房、GIS 等各個領域基本上都有 three.js 的身影。

本文需要對 threejs 的一些基本概念和 api 有一定了解。

如果對 threejs 這部分還不瞭解的可以看下官方文檔和一些中文文檔進行學習。

官方文檔地址:https://threejs.org/

中文文檔地址:http://www.webgl3d.cn/pages/aac9ab/

本文主要主要講述對 threejs 的一些 api 進行基本的封裝,在 vue3 項目中來實現一個可視化的 3d 項目。包含了一些常用的功能,「場景、燈光、攝像機初始化,模型、天空盒的加載,以及鼠標點擊和懸浮的事件交互。」

項目截圖:

Github 地址:https://github.com/fh332393900/threejs-demo

項目預覽地址:https://stevenfeng.cn/threejs-demo/

基礎功能

  1. 場景 Viewer 類

首先我們第一步需要初始化場景、攝像機、渲染器、燈光等。這些功能只需要加載一次,我們都放到 「Viewer」 類中可以分離關注點,在業務代碼中就不需要關注這一部分邏輯。業務代碼中我們只需要關注數據與交互即可。

1.1 初始化場景和攝像機

private initScene() {
  this.scene = new Scene();
}

private initCamera() {
  // 渲染相機
  this.camera = new PerspectiveCamera(25, window.innerWidth / window.innerHeight, 1, 2000);
  //設置相機位置
  this.camera.position.set(4, 2, -3);
  //設置相機方向
  this.camera.lookAt(0, 0, 0);
}

1.2 初始化攝像機控制器

private initControl() {
  this.controls = new OrbitControls(
    this.camera as Camera,
    this.renderer?.domElement
  );
  this.controls.enableDamping = false;
  this.controls.screenSpacePanning = false; // 定義平移時如何平移相機的位置 控制不上下移動
  this.controls.minDistance = 2;
  this.controls.maxDistance = 1000;
  this.controls.addEventListener('change'()=>{
    this.renderer.render(this.scene, this.camera);
  });
}

1.3 初始化燈光

這裏放了一個環境燈光和平行燈光,這裏是寫在 Viewer 類裏面的,如果想靈活一點,也可以抽出去。

private initLight() {
  const ambient = new AmbientLight(0xffffff, 0.6);
  this.scene.add(ambient);

  const light = new THREE.DirectionalLight( 0xffffff );
  light.position.set( 0, 200, 100 );
  light.castShadow = true;

  light.shadow.camera.top = 180;
  light.shadow.camera.bottom = -100;
  light.shadow.camera.left = -120;
  light.shadow.camera.right = 400;
  light.shadow.camera.near = 0.1;
  light.shadow.camera.far = 400;
  // 設置mapSize屬性可以使陰影更清晰,不那麼模糊
  light.shadow.mapSize.set(1024, 1024);

  this.scene.add(light);
}

1.4 初始化渲染器

private initRenderer() {
  // 獲取畫布dom
  this.viewerDom = document.getElementById(this.id) as HTMLElement;
  // 初始化渲染器
  this.renderer = new WebGLRenderer({
    logarithmicDepthBuffer: true,
    antialias: true, // true/false表示是否開啓反鋸齒
    alpha: true, // true/false 表示是否可以設置背景色透明
    precision: 'mediump', // highp/mediump/lowp 表示着色精度選擇
    premultipliedAlpha: true, // true/false 表示是否可以設置像素深度(用來度量圖像的分辨率)
    // preserveDrawingBuffer: false, // true/false 表示是否保存繪圖緩衝
    // physicallyCorrectLights: true, // true/false 表示是否開啓物理光照
  });
  this.renderer.clearDepth();

  this.renderer.shadowMap.enabled = true;
  this.renderer.outputColorSpace = SRGBColorSpace; // 可以看到更亮的材質,同時這也影響到環境貼圖。
  this.viewerDom.appendChild(this.renderer.domElement);
}

Viewer 裏面還加了一些 addAxis 添加座標軸、addStats 性能監控等輔助的公用方法。具體可以看倉庫完整代碼。

1.5 鼠標事件

裏面主要使用了 「mitt」 這個庫,來發布訂閱事件。

threejs 裏面的鼠標事件主要通過把屏幕座標轉換成 3D 座標。通過raycaster.intersectObjects方法轉換。

/**註冊鼠標事件監聽 */
public initRaycaster() {
  this.raycaster = new Raycaster();

  const initRaycasterEvent: Function = (eventName: keyof HTMLElementEventMap)void ={
    const funWrap = throttle(
      (event: any) ={
        this.mouseEvent = event;
        this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
        this.mouse.y = - (event.clientY / window.innerHeight) * 2 + 1;
        // @ts-expect-error
        this.emitter.emit(Events[eventName].raycaster, this.getRaycasterIntersectObjects());
      },
      50
    );
    this.viewerDom.addEventListener(eventName, funWrap, false);
  };

  // 初始化常用的幾種鼠標事件
  initRaycasterEvent('click');
  initRaycasterEvent('dblclick');
  initRaycasterEvent('mousemove');
}

/**自定義鼠標事件觸發的範圍,給定一個模型組,對給定的模型組鼠標事件才生效 */
public setRaycasterObjects (objList: THREE.Object3D[]): void {
  this.raycasterObjects = objList;
}

private getRaycasterIntersectObjects(): THREE.Intersection[] {
  if (!this.raycasterObjects.length) return [];
  this.raycaster.setFromCamera(this.mouse, this.camera);
  return this.raycaster.intersectObjects(this.raycasterObjects, true);
}

「通過 setRaycasterObjects 方法,傳遞一個觸發鼠標事件的模型範圍,可以避免在整個場景中都去觸發鼠標事件。這裏也可以用一個 Map 去存不同模型的事件,在取消訂閱時再移除。」

使用方式:

let viewer: Viewer;
viewer = new Viewer('three');

viewer.initRaycaster();

viewer.emitter.on(Event.dblclick.raycaster, (list: THREE.Intersection[]) ={
  onMouseClick(list);
});

viewer.emitter.on(Event.mousemove.raycaster, (list: THREE.Intersection[]) ={
  onMouseMove(list);
});
  1. 模型加載器 ModelLoder 類

模型的加載我們需要用的 threejs 裏面的,「GLTFLoader」「DRACOLoader」 這兩個類。

模型加載器 ModelLoder 初始化的時候需要把 Viewer 的實例傳進去。

需要注意的是,需要把 draco 從 node_modules 拷貝到項目的 「public」 目錄中去。

實現代碼:

import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import BaseModel from '../BaseModel';
import type Viewer from '../Viewer';

type LoadModelCallbackFn<T = any> = (arg: T) => any;

/**模型加載器 */
export default class ModelLoder {
  protected viewer: Viewer;
  private gltfLoader: GLTFLoader;
  private dracoLoader: DRACOLoader;

  constructor(viewer: Viewer, dracolPath: string = '/draco/') {
    this.viewer = viewer;
    this.gltfLoader = new GLTFLoader();
    this.dracoLoader = new DRACOLoader();

    // 提供一個DracLoader實例來解碼壓縮網格數據
    // 沒有這個會報錯 dracolPath 默認放在public文件夾當中
    this.dracoLoader.setDecoderPath(dracolPath);
    this.gltfLoader.setDRACOLoader(this.dracoLoader);
  }

  /**模型加載到場景 */
  public loadModelToScene(url: string, callback: LoadModelCallbackFn<BaseModel>) {
    this.loadModel(url, model ={
      this.viewer.scene.add(model.object);
      callback && callback(model);
    });
  }

  private loadModel(url: string, callback: LoadModelCallbackFn<BaseModel>) {
    this.gltfLoader.load(url, gltf ={
      const baseModel = new BaseModel(gltf, this.viewer);
      callback && callback(baseModel);
    });
  }
}
  1. 模型 BaseModel 類

這裏對模型外面包了一層,做了一些額外的功能,如模型克隆、播放動畫、設置模型特性、顏色、材質等方法。

/**
* 設置模型動畫
* @param i 選擇模型動畫進行播放
*/
public startAnima(i = 0) {
  this.animaIndex = i;
  if (!this.mixer) this.mixer = new THREE.AnimationMixer(this.object);
  if (this.gltf.animations.length < 1) return;
  this.mixer.clipAction(this.gltf.animations[i]).play();
  // 傳入參數需要將函數與函數參數分開,在運行時填入
  this.animaObject = {
    fun: this.updateAnima,
    content: this,
  };
  this.viewer.addAnimate(this.animaObject);
}

private updateAnima(e: any) {
  e.mixer.update(e.clock.getDelta());
}

還有一些其他方法的實現,可以看倉庫代碼。

  1. 天空盒 SkyBoxs 類

import * as THREE from 'three';
import type Viewer from '../Viewer';
import { Sky } from '../type';

/** 場景天空盒*/
export default class SkyBoxs {
  protected viewer: Viewer;
  
  constructor (viewer: Viewer) {
    this.viewer = viewer;
  }

  /**
   * 添加霧效果
   * @param color 顏色
   */
  public addFog (color = 0xa0a0a0, near = 500, far = 2000) {
    this.viewer.scene.fog = new THREE.Fog(new THREE.Color(color), near, far);
  }

  /**
   * 移除霧效果
   */
  public removeFog () {
    this.viewer.scene.fog = null;
  }

  /**
   * 添加默認天空盒
   * @param skyType
   */
  public addSkybox (skyType: keyof typeof Sky = Sky.daytime) {
    const path = `/skybox/${Sky[skyType]}/`; // 設置路徑
    const format = '.jpg'; // 設定格式
    this.setSkybox(path, format);
  }

  /**
   * 自定義添加天空盒
   * @param path 天空盒地址
   * @param format 圖片後綴名
   */
  private setSkybox (path: string, format = '.jpg') {
    const loaderbox = new THREE.CubeTextureLoader();
    const cubeTexture = loaderbox.load([
      path + 'posx' + format,
      path + 'negx' + format,
      path + 'posy' + format,
      path + 'negy' + format,
      path + 'posz' + format,
      path + 'negz' + format,
    ]);
    // 需要把色彩空間編碼改一下
    cubeTexture.encoding = THREE.sRGBEncoding;
    this.viewer.scene.background = cubeTexture;
  }
}
  1. 模型輪廓輔助線

通過 BoxHelper 可以實現簡單的鼠標選中的特效。

也可以通過 OutlinePass 實現發光的特效。

這裏有一篇關於 threejs 中輪廓線、邊框線、選中效果實現的 N 種方法以及性能評估的文章:https://zhuanlan.zhihu.com/p/462329055

import {
  BoxHelper,
  Color,
  Object3D
} from 'three';
import type Viewer from '../Viewer';

export default class BoxHelperWrap {
  protected viewer: Viewer;
  public boxHelper: BoxHelper;

  constructor (viewer: Viewer, color?: number) {
    this.viewer = viewer;
    const boxColor = color === undefined ? 0x00ffff : color;
    this.boxHelper = new BoxHelper(new Object3D(), new Color(boxColor));
    // // @ts-expect-error
    // this.boxHelper.material.depthTest = false;

    this.initBoxHelperWrap();
  }

  private initBoxHelperWrap () {
    this.viewer.scene.add(this.boxHelper);
  }

  public setVisible (visible: boolean): void {
    this.boxHelper.visible = visible;
  }

  public attach (obj: Object3D): void {
    this.boxHelper.setFromObject(obj);
    this.setVisible(true);
  }

  public dispose (): void {
    const parent = this.boxHelper.parent;
    if (parent !== null) {
      parent.remove(this.boxHelper);
    }

    Object.keys(this).forEach(key ={
      // @ts-expect-error
      this[key] = null;
    });
  }
}

使用方式:

let modelLoader = new ModelLoader(viewer);

boxHelperWrap = new BoxHelperWrap(viewer);

boxHelperWrap.setVisible(false);

推薦項目

以上功能的封裝主要參考了以下幾個比較不錯的項目

https://github.com/alwxkxk/iot-visualization-examples

https://gitee.com/303711888/threejs-park

還有一個用 vue3 hooks 來寫的

https://github.com/fengtianxi001/MF-TurbineMonitor

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