用vue3+threejs写一个太阳光日照模拟

969 阅读3分钟

效果预览:

项目已经开源,包含部分注释,如果对你有帮助,欢迎start。github.com/IrisPro/3d-…

功能介绍

  • 实时查看一个建筑物在某一天的某个时刻的光照情况
  • 可视化地查看建筑日照时长的情况

核心实现

当前仅列出核心步骤,具体实现欢迎查看源码

技术栈:

  • threejs
  • vue3
  • vantUI
  • suncalc.js: 通过经纬度和位置,计算太阳运行的轨迹

素材准备

  • 背景图:我的图片一张地图
  • 普通模型:glb、gltf、obj等格式都可以,我的模型是自己使用sketchup建模的
  • 具有颜色的模型(可选):如上图所示的日照时长,我使用日照大师做出来的

部分实现

threejs相关初始化

...
  init() {
    this.initScene() // 初始化场景
    this.initCamera() // 初始化相机
    this.initRender() // 初始化渲染器

    this.orbitHelper() // 轨道控制器
    this.statsHelper() //性能辅助
    this.animate() // 动画

    window.onresize = this.onWindowResize.bind(this) // 监听屏幕变化
  }
  
 // 加载模型:解码、设置物体接受阴影
 loadModel() {
    const loader = new GLTFLoader()
    /**
     * DRACOLoader 解码器介绍:
     * 在Three.js中,加载GLB模型时是否需要DRACOLoader解码器取决于您的模型文件是否使用了Draco压缩。
     * DRACOLoader是一个用于解码Draco压缩网格数据的加载器。
     * 如果您的GLB模型文件中的几何体数据已经通过 Draco 压缩过,那么在加载时就需要使用DRACOLoader进行解码。
     * 如果没有进行Draco压缩,那么直接使用GLTFLoader即可加载,无需额外设置DRACOLoader。
     */
    /**
     * 如果您使用gltf-pipeline工具对模型进行了Draco压缩,
     * 那么在加载这个压缩后的GLTF或GLB文件时,
     * 就需要用到DRACOLoader来解码Draco压缩过的几何数据。
     */

    const dracoLoader = new DRACOLoader()

    /**
     * 设置Draco解码库
     * Path: node_modules/three/examples/jsm/libs/draco文件复制到public文件下
     */
    dracoLoader.setDecoderPath('./draco/')
    dracoLoader.setDecoderConfig({ type: 'js' }) // 使用js方式解压
    dracoLoader.preload() // 初始化_initDecoder 解码器
    loader.setDRACOLoader(dracoLoader) // 设置gltf加载器dracoLoader解码器

    loader.load(
      this.modelUrl,
      (gltf) => {
        const model = gltf.scene

        // 遍历模型中的所有子对象,设置阴影接收和投射属性
        model.traverse((child) => {
          if (child.isMesh) {
            const copyMaterial = child.material.clone()
            copyMaterial.side = THREE.DoubleSide
            copyMaterial.originColor = copyMaterial.color.clone()
            copyMaterial.color.setHex(0xfffff0)
            child.material = copyMaterial

            //物体遮挡阴影
            child.castShadow = true
            child.receiveShadow = true
          }
        })

        model.scale.set(this.modelScale, this.modelScale, this.modelScale)
        this.building = gltf.scene
        this.scene.add(gltf.scene)
      },
      undefined,
      function (error) {
        console.error(error)
      },
    )
  }
 // 添加自然光、太阳光
 ...
     // 半球光,会比环境光颜色更自然
    const hemisphereLight = new THREE.HemisphereLight(0xffffff, 0xffffff, 1) // (sky color, floor color)
    this.scene.add(hemisphereLight)
 ...
 
 initLight() {
    this.sunLight = new THREE.DirectionalLight(0xffffff)
    this.sunLight.visible = true
    this.sunLight.intensity = 20 //光线的密度,默认为1。 光照越强,物体表面就更明亮
    this.sunLight.shadow.camera.near = -1000 //产生阴影的最近距离
    this.sunLight.shadow.camera.far = 1000 //产生阴影的最远距离
    this.sunLight.shadow.camera.left = -1000 //产生阴影距离位置的最左边位置
    this.sunLight.shadow.camera.right = 1000 //最右边
    this.sunLight.shadow.camera.top = 1000 //最上边
    this.sunLight.shadow.camera.bottom = -1000 //最下面
    this.sunLight.shadow.bias = -0.01 //用于解决阴影水波纹条纹阴影的问题
    this.sunLight.shadow.mapSize.set(4096, 4096) //阴影清晰度

    //告诉平行光需要开启阴影投射,物体遮挡阴影
    this.sunLight.castShadow = true
    this.scene.add(this.sunLight)
  }
 // 
...

// 省略...... 感兴趣可以查看源码

通过经纬度和位置,计算太阳运行的轨迹

// 计算太阳的位置
  const calcSunPosition = () => {
    const sunPosition = SunCalc.getPosition(
      curDate.value,
      latitude.value,
      longitude.value,
    )

    // 太阳角度偏移(可选)
    const offSetRad =
      sunOffset.value > 0 ? THREE.MathUtils.degToRad(sunOffset.value) : 0

    const sunDirection = new THREE.Vector3()
    sunDirection.setFromSphericalCoords(
      1,
      Math.PI / 2 - sunPosition.altitude,
      -sunPosition.azimuth - offSetRad,
    )
    sunDirection.normalize()

    //光源到原点的距离
    sunlightPosition.value = sunDirection.clone().multiplyScalar(200)
  }

创建标签

/**
 * 创建标签示例
 */

import * as THREE from 'three'
import { labelRenderer as labelRenderer2D, tag as tag2D } from '@/core/tag2D'
import { ref } from 'vue'

export const useTag = (threeObj) => {
  const labelRenderer2DObj = ref(null)

  const handleClickTag = (e) => {
    console.log('点击了', e.target.innerHTML)
  }

  const createTag = (list = []) => {
    const tagList = [
      {
        className: 'tag',
        modelName: '可选(自定义属性)',
        name: '1栋',
        position: new THREE.Vector3(14.163, 16.252, 141.966),
      },
      {
        className: 'tag',
        modelName: 'xxx',
        name: '2栋',
        position: new THREE.Vector3(3.216, 74.810, 103.162),
      },
      ...
    ]

    tagList.forEach(({ name, position, modelName, className }) => {
      const label2D = tag2D(name, modelName, className, handleClickTag)
      position.multiplyScalar(threeObj.modelScale)
      label2D.position.copy(position)
      threeObj.scene.add(label2D)
    })

    if (!labelRenderer2DObj.value) {
      labelRenderer2DObj.value = labelRenderer2D(threeObj.container)
    }

    return labelRenderer2DObj
  }

  const renderTag = (scene, camera) => {
    return labelRenderer2DObj.value?.render(
      scene ?? threeObj.scene,
      camera ?? threeObj.camera,
    )
  }

  return {
    createTag,
    renderTag,
  }
}