Visual LabINTERACTIVE LEARNING
OpenGL 与 3D精校教程

Web Worker 模型加载器

把 glTF 解析、几何合并和可转移对象搬到 Worker,降低大模型处理对主线程的阻塞。

Web WorkerglTF性能
专题预计 44 分钟查看源文 ↗

worker

重写了加载过程

import CustGLTFLoader from '@/workers/gltf-loader.js'
import { LoadingManager, FrontSide, DoubleSide, Mesh } from 'three'
import { mergeGeometries } from './BufferGeometryUtils.js'

self.onmessage = async function (e) {
  const { isMainModel, files, model_id } = e.data
  postMessage({ type: 1 })
  const modelGroup = await loadAndMergeGLTF(model_id, files, isMainModel)
  if (modelGroup) {
    genMainModelMsg(modelGroup)
  }
}

async function genMainModelMsg(modelGroup) {
  const materialMaps = {}
  modelGroup.updateMatrixWorld()
  modelGroup.traverse(mesh => {
    if (mesh instanceof Mesh) {
      if (mesh.material.uuid in materialMaps) {
        materialMaps[mesh.material.uuid].meshes.push(mesh)
      } else {
        materialMaps[mesh.material.uuid] = {
          material: mesh.material,
          meshes: [mesh],
        }
      }
    }
  })
  const geometryMaps = {}
  for (const key in materialMaps) {
    const { material, meshes } = materialMaps[key]

    const geometryList = meshes.map((mesh, index) => {
      const geometry = mesh.geometry.clone() //没必要克隆
      geometry.applyMatrix4(mesh.matrixWorld)
      mesh.geometry.dispose()
      return geometry
    })
    const mergedGeometry = mergeGeometries(geometryList, true)
    if(mergedGeometry){
      mergedGeometry.computeVertexNormals()
    } else {
      // 合并失败,则不处理
      continue
    }
    geometryMaps[key] = {
      material,
      geometry: mergedGeometry,

      groups: mergedGeometry.groups,
      originalMeshes: meshes.map(mesh => {
        const originalGeometry = mesh.geometry
        return {
          boundingBox: originalGeometry.boundingBox.clone().applyMatrix4(mesh.matrixWorld),
          boundingSphere: originalGeometry.boundingSphere.clone().applyMatrix4(mesh.matrixWorld),
          assimpMeshName: mesh.userData.assimpMeshName,
        }
      }),
    }
  }
  const transferableObjects= []

  const geometryData = {}

  for (const key in geometryMaps) {
    const { material, geometry, originalMeshes } = geometryMaps[key]
    const buffers = {}
    for (const attributeName in geometry.attributes) {
      const attribute = geometry.attributes[attributeName]
      buffers[attributeName] = attribute.array.buffer
      transferableObjects.push(attribute.array.buffer)
    }

    if (geometry.index) {
      buffers['index'] = geometry.index.array.buffer
      transferableObjects.push(geometry.index.array.buffer)
    }

    const jsons = material.toJSON()
    //删除无用的属性
    for (const key of ['images', 'metadata', 'textures', 'envMapRotation']) {
      if (Reflect.has(jsons, key)) delete jsons[key]
    }

    geometryData[key] = {
      attributes: Object.keys(geometry.attributes).reduce((acc, name) => {
        const attr = geometry.attributes[name]
        acc[name] = {
          itemSize: attr.itemSize,
          normalized: attr.normalized,
          array: attr.array,
        }
        return acc
      }, {}),
      index: geometry.index
        ? {
            array: geometry.index.array,
          }
        : null,
      materialProps: {
        jsons,
        type: material.type,
        map: material.map?.source?.data instanceof ImageBitmap ? material.map.source.data : null,
        metalnessMap: material.metalnessMap?.source?.data instanceof ImageBitmap ? material.metalnessMap.source.data : null,
        roughnessMap: material.roughnessMap?.source?.data instanceof ImageBitmap ? material.roughnessMap.source.data : null,
      },
      originalMeshes: originalMeshes.map(mesh => ({
        boundingBox: {
          min: { x: mesh.boundingBox.min.x, y: mesh.boundingBox.min.y, z: mesh.boundingBox.min.z },
          max: { x: mesh.boundingBox.max.x, y: mesh.boundingBox.max.y, z: mesh.boundingBox.max.z },
        },
        boundingSphere: {
          center: { x: mesh.boundingSphere.center.x, y: mesh.boundingSphere.center.y, z: mesh.boundingSphere.center.z },
          radius: mesh.boundingSphere.radius,
        },
        assimpMeshName: mesh.assimpMeshName,
      })),
      groups: geometry.groups,
    }
    if (geometryData[key].materialProps.map) {
      transferableObjects.push(geometryData[key].materialProps.map)
    }
    material.dispose()
  }
  self.postMessage(
    {
      type: 4,
      geometryData,
    },
    transferableObjects
  )
}

worker-load

async function loadAndMergeGLTF(model_id, files, isMainModel = false) {
  let blobs = new Map()
  let gltfName = ''
  let isBulked = false // 标识模型是单个文件还是分离的文件
  files.forEach(file => {
    const { name: modelName } = file
    blobs.set(modelName, file)
    if (modelName.toLowerCase().endsWith('.gltf') || modelName.toLowerCase().endsWith('.glb')) {
      gltfName = modelName
    }
    if (modelName.toLowerCase().endsWith('.bin')) {
      isBulked = true
    }
  })

  // 取出内存中的 map
  let fileURLMap = new Map()
  let mainModelUrl = null
  let loadUrl
  const args = {
    side: isMainModel ? DoubleSide : FrontSide,
    isStandMat: isMainModel,
    manager: null,
  }
  if (!isBulked) {
    mainModelUrl = URL.createObjectURL(blobs.get(gltfName)) // 单个文件直接做成 ObjectURL
    fileURLMap.set(gltfName, mainModelUrl)
    loadUrl = mainModelUrl
  } else {
    const manager = new LoadingManager()
    loadUrl = gltfName
    manager.setURLModifier(url => {
      const pruneUrl = url.startsWith('./') ? url.slice(2) : url
      let objUrl = fileURLMap.get(pruneUrl)
      if (!objUrl) {
        //map中不存在url
        objUrl = URL.createObjectURL(blobs.get(pruneUrl))
        fileURLMap.set(pruneUrl, objUrl)
      }
      return objUrl
    })
    manager.onProgress = (_url, itemsLoaded, itemsTotal) => {
      postMessage({
        model_id,
        percent: ((itemsLoaded / itemsTotal) * 100 - 3) | 0,
        type: 2,
      })
    }
    manager.onError = url => {
      postMessage({
        model_id,
        type: 3,
        error: `Manager There was an error loading ${url}`,
      })
    }
    args.manager = manager
  }

  return new Promise((resolve, reject) => {
    const gltfLoader = new CustGLTFLoader(args)
    gltfLoader.load(
      loadUrl,
      async gltf => {
        const { scene: modelGroup } = gltf
        //
        // 主模型则要释放所有的url
        fileURLMap.forEach(url => URL.revokeObjectURL(url))
        //释放内存
        fileURLMap = null
        //释放内存
        blobs = null
        resolve(modelGroup)
      },
      progress => {
        if (progress.total !== 0 && progress.total >= progress.loaded) {
          //单个文件加载使用这个方法回调计算进度
          if (!isBulked) {
            postMessage({
              percent: Math.floor((100 * progress.loaded) / progress.total) - 3,
              model_id,
              type: 2,
            })
          }
        }
      },
      error => {
        blobs = null //释放内存
        postMessage({
          model_id,
          type: 3,
          error: `CustGLTFLoader onError => ${error}`,
        })
        reject(error)
      }
    )
  })
}

Rebuild

public async loadModelFromFile(args: ILoadModel) {
    const { model_id, files, onProgress, isMainModel = false } = args
    // let loaderWorker = null
    window.three = this
    let loaderWorker = new myworker()
    this.loadingModel = true
    if (loaderWorker) {
      const filesArrayBuffer = await Promise.all(files.map(file => file.arrayBuffer()))
      const msg = { model_id, isMainModel, files }
      loaderWorker.postMessage(msg, filesArrayBuffer)
      onProgress &&
        onProgress({
          model_id,
          percent: 3,
          type: 2,
        })
      return new Promise<THREE.Group | void>((resolve, reject) => {
        loaderWorker.onmessage = e => {
          const { type } = e.data
          switch (type) {
            case 1:
              this.loadingModel = true
              break
            case 2: // 配景模型加载进度
              onProgress && onProgress(e.data)
              break
            case 3: // 模型加载错误
              console.error(e.data.error)
              message.error('【gltf】解析模型错误', {
                duration: 0,
                closable: true,
              })
              this.loadingModel = false
              loaderWorker.terminate()
              loaderWorker = null
              reject(e.data.error)
              break
            case 4: // 主模型加载完成
              resolve(this.reBuildMesh(e.data))
              onProgress &&
                onProgress({
                  model_id,
                  percent: 101,
                  type: 2,
                })
              this.loadingModel = false
              loaderWorker.terminate()
              loaderWorker = null
              break
          }
        }
      })
    }
  }

  public reBuildMesh(data: { geometryData: Record<string, IGeometryData> }) {
    const modelGroup = new THREE.Group()
    const { geometryData } = data
    for (const key in geometryData) {
      const { attributes, index, materialProps, originalMeshes, groups } = geometryData[key]
      // Recreate the geometry
      const geometry = new THREE.BufferGeometry()
      for (const attrName in attributes) {
        const attr = attributes[attrName]
        geometry.setAttribute(attrName, new THREE.BufferAttribute(attr.array, attr.itemSize, attr.normalized))
      }
      if (index) {
        geometry.setIndex(new THREE.BufferAttribute(index.array, 1))
      }

      // Recreate the material
      let material: THREE.MeshBasicMaterial | THREE.MeshStandardMaterial = null
      const { jsons } = materialProps
      if (materialProps.type === 'MeshStandardMaterial') {
        // 标准材质,如有金属贴图和粗糙度贴图,则需要单独处理,否则直接使用
        material = new THREE.MeshStandardMaterial(jsons)
        if (materialProps.metalnessMap) {
          material.metalnessMap = new THREE.Texture(materialProps.metalnessMap)
          material.metalnessMap.needsUpdate = true
        }
        if (materialProps.roughnessMap) {
          material.roughnessMap = new THREE.Texture(materialProps.roughnessMap)
          material.roughnessMap.needsUpdate = true
        }
        // material.envMap = this.envMap
      } else {
        // Default to MeshBasicMaterial if type is not recognized
        material = new THREE.MeshBasicMaterial(jsons)
      }
      material.onBeforeCompile = shader => {
        // 添加 uniform
        shader.uniforms.uvScale = { value: new THREE.Vector2(1.0, 1.0) }
        // 添加 uniform 声明
        const uv_vertex_glsl = uv_vertex.replace(
          'vMapUv = ( mapTransform * vec3( MAP_UV, 1 ) ).xy;',
          'vMapUv = ( mapTransform * vec3( MAP_UV * uvScale, 1 ) ).xy;'
        )
        // 添加 uniform 声明
        shader.vertexShader = shader.vertexShader.replace(
          '#include <common>',
          `
#include <common>
uniform vec2 uvScale;
          `
        )
        // 修改 MAP_UV 的使用方式
        shader.vertexShader = shader.vertexShader.replace(
          '#include <uv_vertex>',
          `
${uv_vertex_glsl}
          `
        )
        // 保存 shader 引用以便后续更新
        material.userData.shader = shader
      }

      // Set the texture if it exists
      if (materialProps.map) {
        const texture = new THREE.Texture(materialProps.map)
        texture.name = materialProps.jsons.name
        texture.needsUpdate = true
        texture.flipY = false
        texture.wrapS = texture.wrapT = THREE.RepeatWrapping // 相当于 x 轴的平铺方式为镜像平铺
        texture.colorSpace = THREE.SRGBColorSpace // 纹理编码方式

        material.map = texture
      }

      material.name = materialProps.jsons.name
      if (groups) {
        geometry.groups = groups
      }
      // 保存材质
      this.mtlsMap[materialProps.jsons.name] = material
      // Create the mesh and add it to the group
      const mesh = new MergeMesh(geometry, material, originalMeshes)
      mesh.castShadow = true // 对象是否被渲染到阴影贴图中
      mesh.receiveShadow = true
      // 保存uuid与原始材质的映射关系
      this.initMtlIdNameMaps[material.uuid] = materialProps.jsons.name
      // 保存原始材质uuid与mesh的映射关系
      this.initMtlIdMeshMaps[material.uuid] = mesh
      // 保存原始材质uuid
      mesh.userData['muuid'] = material.uuid
      // 保存uuid与mesh的映射关系
      this.meshesMap[mesh.uuid] = mesh

      modelGroup.add(mesh)
    }
    return modelGroup
  }

mergeMesh

重写了mesh的实现,拾取交点的判定方法


import { Vector3, Vector2, Sphere, Matrix4, Ray, Mesh, Triangle, BackSide, FrontSide, MeshBasicMaterial, BufferGeometry } from 'three'

const _inverseMatrix = /*@__PURE__*/ new Matrix4()
const _ray = /*@__PURE__*/ new Ray()
const _sphere = /*@__PURE__*/ new Sphere()
const _sphereHitAt = /*@__PURE__*/ new Vector3()

const _vA = /*@__PURE__*/ new Vector3()
const _vB = /*@__PURE__*/ new Vector3()
const _vC = /*@__PURE__*/ new Vector3()

const _tempA = /*@__PURE__*/ new Vector3()
const _morphA = /*@__PURE__*/ new Vector3()

const _uvA = /*@__PURE__*/ new Vector2()
const _uvB = /*@__PURE__*/ new Vector2()
const _uvC = /*@__PURE__*/ new Vector2()

const _normalA = /*@__PURE__*/ new Vector3()
const _normalB = /*@__PURE__*/ new Vector3()
const _normalC = /*@__PURE__*/ new Vector3()

const _intersectionPoint = /*@__PURE__*/ new Vector3()
const _intersectionPointWorld = /*@__PURE__*/ new Vector3()

merge-raycast

class MergeMesh extends Mesh {
  constructor(geometry = new BufferGeometry(), material = new MeshBasicMaterial(), originalMeshes = []) {
    super()

    this.isMesh = true

    this.type = 'Mesh'

    this.geometry = geometry
    this.material = material
    // 原始的mesh
    this.originalMeshes = originalMeshes ?? []

    this.updateMorphTargets()
  }

  raycast(raycaster, intersects) {
    // console.log('raycast :>> ')
    const geometry = this.geometry
    const material = this.material
    const matrixWorld = this.matrixWorld

    if (material === undefined) return

    // test with bounding sphere in world space

    if (geometry.boundingSphere === null) geometry.computeBoundingSphere()

    _sphere.copy(geometry.boundingSphere)
    _sphere.applyMatrix4(matrixWorld)

    // check distance from ray origin to bounding sphere

    _ray.copy(raycaster.ray).recast(raycaster.near)

    if (_sphere.containsPoint(_ray.origin) === false) {
      if (_ray.intersectSphere(_sphere, _sphereHitAt) === null) return

      if (_ray.origin.distanceToSquared(_sphereHitAt) > (raycaster.far - raycaster.near) ** 2) return
    }
    // console.log('outer sphere')
    // convert ray to local space of mesh

    _inverseMatrix.copy(matrixWorld).invert()
    _ray.copy(raycaster.ray).applyMatrix4(_inverseMatrix)

    // test with bounding box in local space
    //
    if (geometry.boundingBox !== null) {
      if (_ray.intersectsBox(geometry.boundingBox) === false) return
    }
    // 遍历每个合并前的原始mesh
    if (this.originalMeshes.length > 0){
      for (let i = 0, il = this.originalMeshes.length; i < il; i++) {
        const { boundingBox, boundingSphere } = this.originalMeshes[i]
        const { start, count } = this.geometry.groups[i]
        // 射线检测boundingSphere
        if (boundingSphere !== null) {
          _sphere.copy(boundingSphere)
          _sphere.applyMatrix4(matrixWorld)
          _ray.copy(raycaster.ray).recast(raycaster.near)
          if (_sphere.containsPoint(_ray.origin) === false) {
            if (_ray.intersectSphere(_sphere, _sphereHitAt) === null) continue
            if (_ray.origin.distanceToSquared(_sphereHitAt) > (raycaster.far - raycaster.near) ** 2) continue
          }
        }

        // 射线检测boundingBox
        _inverseMatrix.copy(matrixWorld).invert()
        _ray.copy(raycaster.ray).applyMatrix4(_inverseMatrix)
        // test with bounding box in local space
        if (boundingBox !== null) {
          if (_ray.intersectsBox(boundingBox) === false) continue
        }
        // console.log('inner box')
        // 射线检测每个原始mesh的三角面
        this._computeIntersections(raycaster, intersects, _ray, start, count)
      }
    } else {
      this._computeIntersections(raycaster, intersects, _ray)
    }

    // test for intersections with geometry

  }
  /**
   * 主要改写这个方法,实现合并mesh的射线检测,首先检测每个子mesh的射线检测,
   * 然后合并结果,子mesh的射线检测,需要根据顶点的偏移量,来计算射线检测
   * 也是需要计算距离的
   * 1. 其实每个子mesh的计算过程,都是一样的,都是通过射线检测,然后计算距离,然后判断是否在射线检测的范围内
   * 2. 子mesh的射线检测,需要根据顶点的偏移量,来计算射线检测
   */
  _computeIntersections(raycaster, intersects, rayLocalSpace, start = 0, count = Infinity) {
    let intersection
    const geometry = this.geometry
    const material = this.material

    const index = geometry.index
    const position = geometry.attributes.position
    const uv = geometry.attributes.uv
    const uv1 = geometry.attributes.uv1
    const normal = geometry.attributes.normal
    const groups = geometry.groups
    const drawRange = geometry.drawRange

    const startIndex = Math.max(start, drawRange.start)
    const endIndex = Math.min(start + count, drawRange.start + drawRange.count)

    if (index !== null) {
      // indexed buffer geometry

      if (Array.isArray(material)) {
        for (let i = 0, il = groups.length; i < il; i++) {
          const group = groups[i]
          const groupMaterial = material[group.materialIndex]

          const groupStart = Math.max(group.start, startIndex)
          const groupEnd = Math.min(index.count, Math.min(group.start + group.count, endIndex))

          for (let j = groupStart, jl = groupEnd; j < jl; j += 3) {
            const a = index.getX(j)
            const b = index.getX(j + 1)
            const c = index.getX(j + 2)

            intersection = checkGeometryIntersection(this, groupMaterial, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c)

            if (intersection) {
              intersection.faceIndex = Math.floor(j / 3) // triangle number in indexed buffer semantics
              intersection.face.materialIndex = group.materialIndex
              intersects.push(intersection)
            }
          }
        }
      } else {
        for (let i = startIndex, il = endIndex; i < il; i += 3) {
          const a = index.getX(i)
          const b = index.getX(i + 1)
          const c = index.getX(i + 2)

          intersection = checkGeometryIntersection(this, material, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c)

          if (intersection) {
            intersection.faceIndex = Math.floor(i / 3) // triangle number in indexed buffer semantics
            intersects.push(intersection)
          }
        }
      }
    } else if (position !== undefined) {
      // non-indexed buffer geometry

      if (Array.isArray(material)) {
        for (let i = 0, il = groups.length; i < il; i++) {
          const group = groups[i]
          const groupMaterial = material[group.materialIndex]

          const groupStart = Math.max(group.start, startIndex)
          const groupEnd = Math.min(position.count, Math.min(group.start + group.count, endIndex))

          for (let j = groupStart, jl = groupEnd; j < jl; j += 3) {
            const a = j
            const b = j + 1
            const c = j + 2

            intersection = checkGeometryIntersection(this, groupMaterial, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c)

            if (intersection) {
              intersection.faceIndex = Math.floor(j / 3) // triangle number in non-indexed buffer semantics
              intersection.face.materialIndex = group.materialIndex
              intersects.push(intersection)
            }
          }
        }
      } else {
        for (let i = startIndex, il = endIndex; i < il; i += 3) {
          const a = i
          const b = i + 1
          const c = i + 2

          intersection = checkGeometryIntersection(this, material, raycaster, rayLocalSpace, uv, uv1, normal, a, b, c)

          if (intersection) {
            intersection.faceIndex = Math.floor(i / 3) // triangle number in non-indexed buffer semantics
            intersects.push(intersection)
          }
        }
      }
    }
  }
}

function checkIntersection(object, material, raycaster, ray, pA, pB, pC, point) {
  let intersect

  if (material.side === BackSide) {
    intersect = ray.intersectTriangle(pC, pB, pA, true, point)
  } else {
    intersect = ray.intersectTriangle(pA, pB, pC, material.side === FrontSide, point)
  }

  if (intersect === null) return null

  _intersectionPointWorld.copy(point)
  _intersectionPointWorld.applyMatrix4(object.matrixWorld)

  const distance = raycaster.ray.origin.distanceTo(_intersectionPointWorld)

  if (distance < raycaster.near || distance > raycaster.far) return null

  return {
    distance: distance,
    point: _intersectionPointWorld.clone(),
    object: object,
  }
}

function checkGeometryIntersection(object, material, raycaster, ray, uv, uv1, normal, a, b, c) {
  object.getVertexPosition(a, _vA)
  object.getVertexPosition(b, _vB)
  object.getVertexPosition(c, _vC)

  const intersection = checkIntersection(object, material, raycaster, ray, _vA, _vB, _vC, _intersectionPoint)

  if (intersection) {
    if (uv) {
      _uvA.fromBufferAttribute(uv, a)
      _uvB.fromBufferAttribute(uv, b)
      _uvC.fromBufferAttribute(uv, c)

      intersection.uv = Triangle.getInterpolation(_intersectionPoint, _vA, _vB, _vC, _uvA, _uvB, _uvC, new Vector2())
    }

    if (uv1) {
      _uvA.fromBufferAttribute(uv1, a)
      _uvB.fromBufferAttribute(uv1, b)
      _uvC.fromBufferAttribute(uv1, c)

      intersection.uv1 = Triangle.getInterpolation(_intersectionPoint, _vA, _vB, _vC, _uvA, _uvB, _uvC, new Vector2())
    }

    if (normal) {
      _normalA.fromBufferAttribute(normal, a)
      _normalB.fromBufferAttribute(normal, b)
      _normalC.fromBufferAttribute(normal, c)

      intersection.normal = Triangle.getInterpolation(_intersectionPoint, _vA, _vB, _vC, _normalA, _normalB, _normalC, new Vector3())

      if (intersection.normal.dot(ray.direction) > 0) {
        intersection.normal.multiplyScalar(-1)
      }
    }

    const face = {
      a: a,
      b: b,
      c: c,
      normal: new Vector3(),
      materialIndex: 0,
    }

    Triangle.getNormal(_vA, _vB, _vC, face.normal)

    intersection.face = face
  }

  return intersection
}

export { MergeMesh }