文章
问答
冒泡
基于React+Umi4+Three.js 实现3D模型数据可视化

开始

💡 Tips:需要对threejs 的基础知识有一定了解,代码中尽量使用ts以及中文注释了。

  • 使用umi4+antd快速搭建项目框架。Umi是可扩展的企业级前端应用框架,也是蚂蚁集团的底层前端框架,Umi 以路由为基础,同时支持配置式路由和约定式路由,保证路由的功能完备,并以此进行功能扩展。然后配以生命周期完善的插件体系,覆盖从源码到构建产物的每个生命周期,支持各种功能扩展和业务需求。

  • 参考开源项目对threejs的api进行基本的封装。建模并不是 Three.js 最核心和擅长的,Three.js 最核心功能是进行 浏览器 3D 场景渲染和交互。

项目地址:https://github.com/Gzx97/umi-three-demo

项目效果:

动画.gif

对于Threejs基本功能的封装

场景Viewer类

我们会在Viewer中封装好 初始化场景、摄像机、渲染器、灯光、鼠标监听,坐标轴、性能面板等。在业务代码中就不需要关注这一部分逻辑。业务代码中我们只需要关注数据与交互即可。对于相关逻辑,代码中皆有注释说明,这一部分代码的编写以及使用需要具备一定3D的基础知识,建议先去使用Threejs创建一个场景实现一两个demo之后就明白这一步的必要性了。

import {
  Scene,
  PerspectiveCamera,
  AxesHelper,
  WebGLRenderer,
  Camera,
  SRGBColorSpace,
  AmbientLight,
  Raycaster,
  Vector2,
} from "three";
import * as THREE from "three";
import mitt, { type Emitter } from "mitt";
import Events from "./Events";
import { throttle } from "lodash";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
export default class Viewer {
   public id: string;
  public viewerDom!: HTMLElement;
  public scene!: Scene;
  public camera!: PerspectiveCamera;
  public renderer!: WebGLRenderer;
  public controls!: OrbitControls;
  public skyboxs!: SkyBoxs;
  public animateEventList: any[] = [];
  public statsControls!: Stats;
  public raycaster!: Raycaster;
  public mouse!: Vector2;
  public emitter!: Emitter<any>;
  public mouseEvent: MouseEvent | undefined;
  public raycasterObjects: THREE.Object3D[] = [];
  public isDestroy = false;

  constructor(id: string) {
    this.id = id;
    this.initViewer();
  }

  // TODO:场景、相机、灯光等
}

首先我们把threejs渲染图像的基本要素在Viewer中初始化出来:

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

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

  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);
    });
  }

 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);
  }

然后使用渲染器把场景以及添加在场景的元素渲染出来。

  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);
  }

因为需要进行鼠标事件监听,所以把鼠标以及射线事件封装进来,并且通过 setRaycasterObjects 方法,传递一个触发鼠标事件的模型范围,可以避免在整个场景中都去触发鼠标事件。这里也可以用一个 Map 去存不同模型的事件,在取消订阅时再移除。

  /**注册鼠标事件监听 */
  public initRaycaster() {
    this.raycaster = new Raycaster();
    const initRaycasterEvent: Function = (
      eventName: keyof HTMLElementEventMap
    ): void => {
      //这里的container就是画布所在的div,也就是说,这个是要拿整个scene所在的容器来界定的
      let getBoundingClientRect = this.viewerDom.getBoundingClientRect();
      let offsetWidth = this.viewerDom.offsetWidth;
      let offsetHeight = this.viewerDom.offsetHeight;
      const funWrap = throttle((event: any) => {
        this.mouseEvent = {
          ...event,
          //真正的鼠标相对于画布的位置
          x: event.clientX - getBoundingClientRect.left,
          y: event.clientY - getBoundingClientRect.top,
        };
        this.mouse.x =
          ((event.clientX - getBoundingClientRect.left) / offsetWidth) * 2 - 1;
        this.mouse.y =
          -((event.clientY - getBoundingClientRect.top) / offsetHeight) * 2 + 1;
        this.emitter.emit(
          (Events as any)[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);
    // console.log(this.raycaster.intersectObjects(this.raycasterObjects, true));
    return this.raycaster.intersectObjects(this.raycasterObjects, true);
  }

除了这些基础的功能,还可以添加性能面板、坐标轴、新增地板场景、天空场景等方法,可以根据实际需求灵活编写运用。


模型加载 ModelLoder

模型的加载我们需要用的threejs里面的,GLTFLoaderDRACOLoader 这两个类。

模型加载器 ModelLoder 初始化的时候需要把 Viewer 的实例传进去。

需要注意的是,需要把 draco 从 node_modules 拷贝到项目的 public 目录中去。

import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { DRACOLoader } from "three/examples/jsm/loaders/DRACOLoader.js";
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 = "/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);
    });
  }
}

import type { Object3DExtends } from "@/types";
import * as THREE from "three";
import type { GLTF } from "three/examples/jsm/loaders/GLTFLoader.js";
import type { Material } from "three";
import { clone } from "three/examples/jsm/utils/SkeletonUtils.js";
import type Viewer from "../Viewer";
import type { Animate } from "../Viewer";

export default class BaseModel {
  protected viewer: Viewer;
  public gltf: GLTF;
  public object: THREE.Group;
  /**模型原始材质 */
  public originMaterials: Material[] = [];
  public isSaveMaterial = false;
  public animaIndex = -1;
  public mixer!: THREE.AnimationMixer;
  public clock: THREE.Clock;
  public animaObject!: Animate;

  constructor(gltf: GLTF, viewer: Viewer) {
    this.gltf = gltf;
    this.viewer = viewer;
    this.object = gltf.scene || gltf;

    this.clock = new THREE.Clock();
  }
  /**
   * 设置模型比例
   * @param x 可以只填写一个参数
   * @param y 纵轴缩放
   * @param z 横轴缩放
   */
  public setScalc(x: number, y?: number, z?: number) {
    this.object.scale.set(x, y || x, z || x);
  }

  public getLength() {
    const box = new THREE.Box3();
    box.setFromObject(this.object);
    const size = box.getSize(new THREE.Vector3());
    return size;
  }

  /**
   * 设置模型动画
   * @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());
  }

  /**
   * 克隆模型
   * @param x
   * @param y
   * @param z
   * @returns {*}
   */
  public cloneModel([x, y, z] = [0, 0, 0]) {
    const newScene = { ...this.gltf };
    const newModel = clone(this.object);
    newModel.position.set(x, y, z);
    this.viewer.scene.add(newModel);
    newScene.scene = newModel as any;
    return new BaseModel(newScene, this.viewer);
  }

  /**
   * 开启模型阴影 数组中移除阴影
   */
  public openCastShadow(names = []) {
    this.gltf.scene.traverse((model: Object3DExtends) => {
      if (model.isMesh && !names.includes(model.name as never)) {
        //它会在渲染对象之前检查每一帧对象是否位于相机的视锥体中。 如果设置为 false,则即使对象不在相机的视锥体中,也会在每一帧进行渲染。
        model.frustumCulled = false;
        model.castShadow = true; //对象是否渲染成阴影贴图。
      }
    });
  }

  /**设置模型颜色 */
  public setColor(color = "yellow", opacity = 0.5) {
    if (!this.isSaveMaterial) this.originMaterials = [];
    this.gltf.scene.traverse((model: Object3DExtends) => {
      if (model.isMesh) {
        if (!this.isSaveMaterial)
          this.originMaterials.push(model.material as Material);
        model.material = new THREE.MeshPhongMaterial({
          side: THREE.DoubleSide,
          transparent: true,
          depthTest: false,
          depthWrite: true, // 无法被选择,鼠标穿透
          color: new THREE.Color(color),
          opacity: opacity,
        });
      }
    });
    this.isSaveMaterial = true;
  }

  /**设置模型材质 */
  public setMaterial(material = new THREE.MeshBasicMaterial()) {
    if (!this.isSaveMaterial) this.originMaterials = [];
    this.gltf.scene.traverse((model: Object3DExtends) => {
      if (model.isMesh) {
        if (!this.isSaveMaterial)
          this.originMaterials.push(model.material as Material);
        model.material = material;
      }
    });
    this.isSaveMaterial = true;
  }

  // 还原模型材质
  public setDefault() {
    let i = 0;
    this.gltf.scene.traverse((model: Object3DExtends) => {
      if (model.isMesh) {
        model.material = this.originMaterials[i];
        i++;
      }
    });
  }
}

使用方式

使用这些封装好的方法,可以实现加载外部glb/gltf的模型文件,并且可以播放文件动画,可以根据模型的结构对相应的模块进行交互操作,包含报警、信息展示、模型部分显示隐藏等交互操作。

import React, { useEffect, useRef, useState } from "react";
import * as THREE from "three";
import Viewer, { Animate } from "@/modules/Viewer";
import ModelLoader from "@/modules/ModelLoder";
import BoxHelperWrap from "@/modules/BoxHelperWrap";
import styles from "./index.less";
import Floors from "@/modules/Floors";
import { checkNameIncludes, findParent } from "@/utils";
import Event from "@/modules/Viewer/Events";
import { Object3DExtends } from "@/types";
import Popover from "./components/Popover";

const PAGE_ID = "FACTORY_CONTAINER";

const ThreeDemo: React.FC = () => {
  const [rackList, setRackList] = useState<THREE.Object3D[]>([]);
  const [showPopover, setShowPopover] = useState<boolean>(false);
  const [popoverPosition, setPopoverPosition] = useState({ top: 0, left: 0 });
  const [popoverData, setPopoverData] = useState({});
  let viewer: Viewer;
  let modelLoader: ModelLoader;
  let boxHelperWrap: BoxHelperWrap;

  // 加载
  const init = () => {
    viewer = new Viewer(PAGE_ID);
    viewer.addAxis();
    viewer.addStats();
    viewer.initRaycaster();

    modelLoader = new ModelLoader(viewer);
    const floors = new Floors(viewer);
    floors.addGird(8, 25, 0x004444, 0x004444);
    boxHelperWrap = new BoxHelperWrap(viewer);
    viewer.emitter.on(
      Event.mousemove.raycaster,
      (list: THREE.Intersection[]) => {
        onMouseMove(list);
      }
    );
    viewer.emitter.on(Event.click.raycaster, (list: THREE.Intersection[]) => {
      onMouseClick(list);
    });
  };
  const checkIsRack = (obj: THREE.Object3D): boolean => {
    return checkNameIncludes(obj, "rack");
  };
  const onMouseClick = (intersects: THREE.Intersection[]) => {
    if (!intersects.length) return;
    const selectedObject = intersects?.[0].object || {};
    selectedObject.visible = !selectedObject.visible;
  };
  const onMouseMove = (intersects: THREE.Intersection[]) => {
    if (!intersects.length) {
      boxHelperWrap.setVisible(false);
      setShowPopover(false);
      return;
    }
    const selectedObject = intersects[0].object || {};
    let selectedObjectName = "";
    const findClickModel = (object: THREE.Object3D) => {
      if (object.type === "Group") {
        selectedObjectName = object.name;
        return;
      }
      if (object.parent && object.type !== "Scene") {
        findClickModel(object.parent);
      }
    };
    findClickModel(selectedObject);
    const rack = findParent(selectedObject, checkIsRack);
    if (rack) {
      // console.log(rack);
      boxHelperWrap.attach(rack);
      updateRackInfo(rack.name);
    }
  };
  const updateRackInfo = (name: string) => {
    if (!name) {
      setShowPopover(false);
      return;
    }
    const event = viewer.mouseEvent as MouseEvent;
    setPopoverPosition({
      top: event.y + 10,
      left: event.x + 10,
    });
    setPopoverData({ title: name });
    setShowPopover(true);
  };

  // 修改颜色
  const changeWarningColor = (model: THREE.Object3D) => {
    model.traverseVisible((item: Object3DExtends) => {
      if (item.isMesh) {
        item.material = new THREE.MeshStandardMaterial({
          metalness: 1.0,
          roughness: 0.5,
        });
        item.material.color = item?.oldMaterial?.warningColor;
      }
    });
  };
  // 通过name修改成警告颜色
  const changeWarningColorByName = (name: string) => {
    console.log(rackList);
    const model = rackList.find((item) => item.name === name);
    if (model) {
      changeWarningColor(model);
    }
  };
  // 加载模型
  const initModel = () => {
    modelLoader.loadModelToScene("/models/datacenter.glb", (baseModel) => {
      // /models/datacenter.glb
      // /models/GuiGu-factory.glb
      console.log(baseModel);
      // 设置基础模型的缩放比例
      baseModel.setScalc(0.15);
      // 暂时注释掉旋转代码
      // baseModel.object.rotation.y = Math.PI / 2;
      // 获取实际的模型对象
      const model = baseModel.gltf.scene;
      model.position.set(0, 0, 0.3);
      // 为模型设置名称
      model.name = "机房1";
      model.uuid = "机房1";
      console.log(model);

      // 启用基础模型的投射阴影功能
      baseModel.openCastShadow();
      let rackList: Object3DExtends[] = [];
      model.traverse((item) => {
        if (checkIsRack(item)) {
          rackList.push(item);
        }
        if (item instanceof THREE.Mesh) {
          // 保存原始颜色数据,以及警告颜色
          if (item.isMesh) {
            item.material.warningColor = {
              r: 1,
              g: 0,
              b: 0,
              isColor: true,
            };
            // 保存旧的材质
            (item as Object3DExtends).oldMaterial = item.material;
          }
        }
      });
      setRackList(rackList);
      // console.log("rackList------", rackList);
      // 将 rackList 中的机架设置为 viewer 的射线检测对象
      viewer.setRaycasterObjects(rackList);
    });
  };
  useEffect(() => {
    init();
    initModel();
    return () => {
      viewer.destroy();
    };
  }, []);
  // 模拟报警测试
  useEffect(() => {
    setTimeout(() => {
      changeWarningColorByName("rackA_3");
    }, 5000);
  }, [rackList]);
  return (
    <div className={styles.wrapper}>
      <div
        id={PAGE_ID}
        style={{ width: 1000, height: 1000, border: "1px solid red" }}
      ></div>
      <Popover show={showPopover} {...popoverPosition} data={popoverData} />
    </div>
  );
};

export default ThreeDemo;

import React from "react";
import styles from "./Popover.less";
import { Descriptions, Typography } from "antd";
export type PopoverProps = {
  top?: number;
  left?: number;
  show?: boolean;
  data?: Record<string, any>;
};

const Popover: React.FC<PopoverProps> = ({
  top = 0,
  left = 0,
  show = false,
  data = { title: "" },
}) => {
  const position = {
    top: `${top}px`,
    left: `${left}px`,
  };
  return (
    <div
      style={{
        ...position,
        display: show ? "block" : "none",
        color: "white",
      }}
      className={styles.wrapper}
    >
      <Descriptions
        labelStyle={{ color: "white" }}
        contentStyle={{ color: "white" }}
        column={1}
        title={<div style={{ color: "white" }}>{data?.title}</div>}
      >
        <Descriptions.Item label="温度">{data?.title}℃</Descriptions.Item>
        <Descriptions.Item label="湿度">{data?.title}</Descriptions.Item>
        <Descriptions.Item label="Live">Hangzhou, Zhejiang</Descriptions.Item>
        <Descriptions.Item label="Remark">empty</Descriptions.Item>
      </Descriptions>
    </div>
  );
};

export default Popover;

TODO:

基于这些功能,还可以继续封装对模型的控制、可视化的移动、视角切换等功能。敬请期待。

react
three

关于作者

却黑
社恐
获得点赞
文章被阅读