基于Three.js海上风电数字孪生三维效果

远算前端团队,主要分享一些WEBGL、Three.js等三维技术,团队主要做数字孪生相关的数字大屏,项目一般涉及仿真、后处理,以及三维可视化。

欢迎关注公众号 远算前端团队

三维渲染

1.1 过场动画

海上风机数量很多,如何实现不同风机的切换呢?效果视频如下: 哔哩哔哩

实现逻辑:

X2t6l8.jpg

 // 初始 相机位置 & 控制中心位置
 let oldP = this.camera.position;
 let oldT = this.controls.target;
 
 // 要过渡到的 相机位置 & 控制中心位置
 let newP = new THREE.Vector3(
    currentModel.position.x + this.windmillPos.latD + this.cameraPos.x,
    currentModel.position.y + this.cameraPos.y,
    currentModel.position.z + this.windmillPos.longD + this.cameraPos.z,
 );
 let newT = new THREE.Vector3(
    currentModel.position.x + this.windmillPos.latD,
    currentModel.position.y,
    currentModel.position.z + this.windmillPos.longD,
 );
  
 // 动画逻辑
 this.updateCamera(oldP, oldT, newP, newT, this.tweenEnd);
 
   updateCamera(
    oldP: THREE.Vector3,
    oldT: THREE.Vector3,
    newP: THREE.Vector3,
    newT: THREE.Vector3,
    callBack: Function | null = null,
  ) {
    this.tween = new TWEEN.Tween({
      x1: oldP.x, // 相机x
      y1: oldP.y, // 相机y
      z1: oldP.z, // 相机z
      x2: oldT.x, // 控制点的中心点x
      y2: oldT.y, // 控制点的中心点y
      z2: oldT.z, // 控制点的中心点z
    })
      .to(
        {
          x1: newP.x,
          y1: newP.y,
          z1: newP.z,
          x2: newT.x,
          y2: newT.y,
          z2: newT.z,
        },
        2000,
      )
      .onUpdate((object) => {
        this.camera.position.x = object.x1;
        this.camera.position.y = object.y1;
        this.camera.position.z = object.z1;
        this.camera.updateMatrixWorld();

        this.controls.target.x = object.x2;
        this.controls.target.y = object.y2;
        this.controls.target.z = object.z2;
        this.controls.update();
      })
      .easing(TWEEN.Easing.Cubic.InOut)
      .start()
      .onComplete(() => {
        this.eventFlag = false;
        this.controls.enabled = true;
        this.tween.stop();
        this.tween = null;
        callBack && callBack.call(this);
      });
  }
复制代码

1.2 3D精灵图

每个风机的序号是通过精灵图实现

实现逻辑: X2d3He.jpg

  var canvas = document.createElement('canvas');
  var context = canvas.getContext('2d');
  //        ...画图逻辑...
  var texture = new THREE.Texture(canvas);
  texture.needsUpdate = true;

  var spriteMaterial = new THREE.SpriteMaterial({
    map: texture,
    transparent: true,

  });

  var sprite = new THREE.Sprite(spriteMaterial);
  
  sprite.position.set(
    windmill.position.x,
    windmill.position.y + 80 * this.scale,
    windmill.position.z,
  );
  sprite.center.set(0.5, 0);
  
  sprite.scale.set(20 * this.scale, 10 * this.scale, 1.0);
复制代码

1.3 海缆

      pointArr = [point1,point2]
      const flowingLineTexture = await this.textureLoader.load(
        '/images/texture/arrow2.svg',
      );
      flowingLineTexture.wrapS = THREE.RepeatWrapping;
      flowingLineTexture.wrapT = THREE.RepeatWrapping;
      flowingLineTexture.repeat.set(30, 1); //水平重复30次
      // flowingLineTexture.needsUpdate = true;
      // 曲线管道
      let material = new THREE.MeshBasicMaterial({
        map: flowingLineTexture,
        side: THREE.BackSide, //显示背面
        // transparent: true,
        // opacity: 0.99,
      });
      let curve = new THREE.CatmullRomCurve3(pointArr);
      let tubeGeometry = new THREE.TubeGeometry(curve, 60, 4);
      let mesh = new THREE.Mesh(tubeGeometry, material);
      
      
      animate(){
         mesh.material.map.offset.x += 0.01;
         this.animateID = requestAnimationFrame(this.animate.bind(this));
      }
复制代码

1.4 水面掏洞

为了实现每个风机基桩冲刷的效果,当每个风机聚焦的时候,通过水面掏洞,覆盖上云图实现冲刷效果

  // 依赖genThreeBsp函数库
  import genThreeBsp from './genThreeBsp';
  
  const ThreeBSP = genThreeBsp();
  
  // 平面几何体
  const geometry = new THREE.PlaneGeometry(options.size, options.size / 2);
  let totalMesh = this.createMesh(geometry);
  
  // 圆柱体
  let cylinderGeometry = new THREE.CylinderGeometry(
    options.radius,
    options.radius,
    options.height,
    options.segments !== undefined ? 100 : options.segments,
  );
  cylinderGeometry.rotateX(Math.PI / 2); 
  let subMesh = this.createMesh(cylinderGeometry);
  // 进行差集运算
  let resultBsp = totalBsp.subtract(subBsp);
  // 运算结果生成新的 mesh
  const result = resultBsp.toMesh();
  
  result.geometry.computeVertexNormals();
  result.geometry.buffersNeedUpdate = true;
  result.geometry.uvsNeedUpdate = true;
  // 渲染水面
  this.water = new Water(result.geometry, {
    textureWidth: 512,
    textureHeight: 512,
    waterNormals: new THREE.TextureLoader().load(
      '/images/water/waternormals.jpg',
      (texture) => {
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
      },
    ),
    sunColor: 0xffffff,
    waterColor: 0x001e0f,
    eye: new THREE.Vector3(1000, 1000, 1000),
    distortionScale: 3.7,
  });
  // 进行水面旋转,否则渲染不正确
  this.water.rotateX(-Math.PI / 2);
    
  createMesh(geometry) {
    //  创建一个线框纹理
    const wireFrameMat = new THREE.MeshBasicMaterial({
      opacity: 0.5,
      wireframeLinewidth: 0.5,
    });
    wireFrameMat.wireframe = true;
    // 创建模型
    const mesh = new THREE.Mesh(geometry, wireFrameMat);
    return mesh;
  }  
复制代码

其他UI渲染

2.1 屏幕适配

针对不同尺寸的屏幕进行了适配,保证设计稿中能显示出的UI元素等比例显示在浏览器窗口

import store from '../store';
import Operator from './Operator';
((window, store, Operator) => {
  let screenRatioByDesign = 16 / 9; // 设计稿宽高比
  // 延时时间过长时,使用F11切换屏幕全屏到非全屏,页面尺寸来不及进行重置
  let delay = 10; // 防抖延时ms
  let minWidth = 1200; // 最小宽度
  let minHeight = minWidth / screenRatioByDesign;
  let grids = 1920; // 页面栅格份数
  let designWidth = 1920;
  let docEle = document.documentElement;

  const setHtmlFontSize = () => {
    const clientWidth =
      docEle.clientWidth > minWidth ? docEle.clientWidth : minWidth;
    const clientHeight =
      docEle.clientHeight > minHeight ? docEle.clientHeight : minHeight;
    let screenRatio = clientWidth / clientHeight;

    let fontSize =
      ((screenRatio > screenRatioByDesign
        ? screenRatioByDesign / screenRatio
        : 1) *
        clientWidth) /
      grids;

    docEle.style.fontSize = fontSize.toFixed(6) + 'px';
    store.rootFont.updateFontSize(+fontSize.toFixed(6));
  };

  const setWidthRate = () => {
    const clientWidth =
      docEle.clientWidth > minWidth ? docEle.clientWidth : minWidth;
    const widthRate = clientWidth / designWidth;

    store.wRate.updateWidthRate(+widthRate.toFixed(6));
  };

  setHtmlFontSize();
  setWidthRate();

  let operator1 = new Operator('set_root_font_size');
  let operator2 = new Operator('set_width_rate');

  window.addEventListener('resize', () => {
    operator1.debounce(setHtmlFontSize, delay);
    operator2.debounce(setWidthRate);
  });
  window.addEventListener(
    'pageshow',
    (e) => {
      if (e.persisted) {
        operator1.debounce(setHtmlFontSize, delay);
        operator2.debounce(setWidthRate);
      }
    },
    false,
  );
})(window, store, Operator);
复制代码

2.2 bizCharts 自定义图形API——registerShape

registerShape('interval', 'delta-bar', {
  getPoints(pointInfo: { x: number; y: number; y0: number; size: number }) {
    let arr: Array<Point> = [];
    // pointInfo.y0 = 0.1;
    let point1: Point = {
      x: pointInfo.x - pointInfo.size / 2,
      y: pointInfo.y0,
    };
    let point2: Point = {
      x: pointInfo.x - pointInfo.size / 2,
      y:
        pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0 < 0
          ? 0
          : pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0,
    };
    let point3: Point = {
      x: pointInfo.x,
      y: pointInfo.y + pointInfo.y0,
    };
    let point4: Point = {
      x: pointInfo.x + pointInfo.size / 2,
      y:
        pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0 < 0
          ? 0
          : pointInfo.y - (pointInfo.size / 2) * Math.tan(angle) + pointInfo.y0,
    };
    let point5: Point = {
      x: pointInfo.x + pointInfo.size / 2,
      y: pointInfo.y0,
    };
    let point6: Point = {
      x: pointInfo.x,
      y: pointInfo.y0,
    };
    let point7: Point = {
      x: pointInfo.x,
      y:
        pointInfo.y - pointInfo.size * Math.tan(angle) + pointInfo.y0 < 0
          ? 0
          : pointInfo.y - pointInfo.size * Math.tan(angle) + pointInfo.y0,
    };
    arr.push(point1, point2, point3, point4, point5, point6, point7);
    return arr;
  },
  draw(cfg: ShapeInfo, container) {
    const { points } = cfg;
    if (cfg?.data?.deep < maxValue && cfg?.data?.deep >= midValue) {
      color = colors[1];
    } else if (cfg?.data?.deep < midValue) {
      color = colors[2];
    } else {
      color = colors[0];
    }

    let path: Array<Array<string | number>> = [];

    points?.forEach((item) => {
      path.push(['L', item.x, item.y]);
    });

    path = this.parsePath(path); // 将 0 - 1 转化为画布坐标

    let pointsArr = path.map((item: any, index) => {
      return item.filter((innerItem) => {
        return typeof innerItem !== 'string';
      });
    });

    const group = container.addGroup();

    let pointArrLeft = [pointsArr[0], pointsArr[1], pointsArr[6], pointsArr[5]];
    group.addShape('polygon', {
      attrs: {
        points: pointArrLeft,
        fill: color[1],
      },
    });
    let pointArrTop = [pointsArr[1], pointsArr[2], pointsArr[3], pointsArr[6]];
    group.addShape('polygon', {
      attrs: {
        points: pointArrTop,
        fill: color[0],
      },
    });
    let pointArrRight = [
      pointsArr[5],
      pointsArr[6],
      pointsArr[3],
      pointsArr[4],
    ];
    group.addShape('polygon', {
      attrs: {
        points: pointArrRight,
        fill: color[2],
      },
    });
    return group;
  },
});
复制代码

2.3 温度计组件

Canvas 元素,两个宽高

  1. css像素宽高
  2. canvas画布宽高

    let drawer = canvas.current;
    const ctx = drawer?.getContext('2d');
    // 像素比
    let radio = 2;

    let style, width, height, tid, image;
    let y = ((temp + 50) / 100) * (17 - 139) + 139;

    style = window.getComputedStyle(drawer?.parentElement);
    drawer.height = height =
      parseFloat(style.getPropertyValue('height')) * radio;
    drawer.width = width = parseFloat(style.getPropertyValue('width')) * radio;

    // ctx?.scale(radio, radio);
    const img = new Image();
    image = new Image();
    img.src = thermometer;
    img.onload = () => {
      ctx?.drawImage(img, 0, 0, width, height);
      drawLine(ctx);

      image.src = drawer?.toDataURL('image/webp', 1);
    };

    const drawLine = (ctx) => {
      let lineGradient = ctx.createLinearGradient(
        63 * store.rootFont.fontSize * radio,
        17 * store.rootFont.fontSize * radio,
        63 * store.rootFont.fontSize * radio,
        151 * store.rootFont.fontSize * radio,
      );
      lineGradient.addColorStop(0, '#03386C');
      lineGradient.addColorStop(1, '#236FC5');
      ctx.strokeStyle = lineGradient;
      ctx.lineCap = 'round';
      ctx.beginPath();
      ctx.moveTo(
        63 * store.rootFont.fontSize * radio,
        151 * store.rootFont.fontSize * radio,
      );
      ctx.lineTo(
        63 * store.rootFont.fontSize * radio,
        y * store.rootFont.fontSize * radio,
      );
      ctx.lineWidth = 4 * store.rootFont.fontSize * radio;
      ctx.stroke();
复制代码

3D场景性能优化

3.1 清除动画

在组件销毁时一定要清除代码中的 requestAnimationFrame;否则即使当前组件销毁以后,渲染逻辑依然在执行,可能会导致内存溢出;

  stop() {
    this.tid && clearTimeout(this.tid);
    if (this.water) {
      this.water.tid && clearTimeout(this.water.tid);
    }
    this.animateID && cancelAnimationFrame(this.animateID);
  }
复制代码

3.2 自定义渲染帧率

  const FPS = 50; // 指的是 50帧每秒的情况
  const singleFrameTime = 1 / FPS;
  
  animate() {
    this.animateID && cancelAnimationFrame(this.animateID);

    const delta = this.clock.getDelta(); //获取距离上次请求渲染的时间
    timeStamp += delta;
    if (timeStamp > singleFrameTime) {
       this.render();
       timeStamp = timeStamp % singleFrameTime;
     }
    this.render();

    this.animateID = requestAnimationFrame(this.animate.bind(this));
  }
复制代码

3.3 模型处理

当渲染模型过大时,可以去除模型中不是重点的结构或者降低模型中面的细分;

3.4 异步执行

异步执行渲染逻辑中耗时或者比较占用性能的步骤,比如水面倒影渲染的计算,需要占用大部分的CPU,可以异步执行该逻辑;
前提:异步执行的逻辑不影响页面数据和状态传递;对页面渲染的影响不大;

欢迎关注公众号 远算前端团队

猜你喜欢

转载自juejin.im/post/7108486124749717535