你不知道的Three.js性能优化和使用小技巧

前言

在使用Three.js 开发实际项目的过程中,性能 二字永远是一个绕不开的话题,相较于传统的前端业务,3D相关的项目对于性能 方面一定是有更高要求的,除开three.js 本身提供的性能优化方案之外,作者也在three.js的实际项目开发过程中总结出来一些自己的优化方案和开发小技巧。

本篇以个人视角给大家分享一下,作者个人在three.js 开发项目过程中遇到的性能问题以及对应的解决方案

一、鼠标点击选中模型材质的功能有很明显的延迟

在实现类似这样一个给3D模型添加鼠标点击选中效果时功能时

如果说你的场景 中有加载了很多的内容又或者你的模型有很多材质(100+),浏览器控制台就会很容易出现这样的一个提示警告

意思是 你的点击事件回调函数执行时间太长(超过 50ms) ,造成了 主线程阻塞

这个时候你点击模型过后,不会马上出现选中效果,而是会有很明显的延迟

为什么会出现这样的情况?

如果场景scene?.children 内容很多(场景中包含成百上千 个对象),每次点击都触发大量计算或渲染,并且场景中的相机,辅助线等相关内容都会被视作点击对象,因此这样的数据量处理在 50ms 内是无法完成的。

最佳的解决方法:手动过滤一遍场景中可以被点击的模型内容

1.先给可以被选中的模型添加一个判断值 自定义属性(isTransformControls ),在 userData 中添加

js 复制代码
  model.userData = {
    ...model.userData,
    isTransformControls: true,
  };

2.然后通过

scene?.children.filter((obj) => obj.userData?.isTransformControls)

过滤一下需要被选中的模型,这样的话传入到raycaster.intersectObjects中就是可以被点击到的模型,减少程序的执行时间

js 复制代码
function onClick(event: MouseEvent) {
  // 将鼠标坐标转换为归一化设备坐标(范围 -1 ~ 1)
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
  // 更新射线
  raycaster.setFromCamera(mouse, camera)
  // 获取到可以被选中的模型列表
  const clickableObjects = scene?.children.filter((obj) => obj.userData?.isTransformControls);
// 交叉线位置的内容
  const intersects = raycaster.intersectObjects(clickableObjects, true)

  if (intersects.length > 0) {
    const selected = intersects[0].object
    console.log('选中模型:', selected)
  }
}

二、Vue3响应式数据(Proxy)也会造成点击延迟?

这里接着上面内容继续延伸 ⚠️:如果你使用的是 Vue3 并且你的场景数据已经是响应式数据了(Proxy

请一定要将其转化为普通 的数据格式,因为如果是以响应式数据的格式传入到 raycaster.intersectObjects(clickableObjects, true) 中也会出现警告提示

这里可以通过 toRaw 去转化普通数据

js 复制代码
function onClick(event: MouseEvent) {
  // 将鼠标坐标转换为归一化设备坐标(范围 -1 ~ 1)
  mouse.x = (event.clientX / window.innerWidth) * 2 - 1
  mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
  // 更新射线
  raycaster.setFromCamera(mouse, camera)
  // 获取到可以被选中的模型列表
  const clickableObjects = Array.from(scene?.children || [])
      .map((obj) => toRaw(obj))
      .filter(
        (obj) => obj.userData?.isTransformControls
      );
  // 交叉线位置的内容
  const intersects = this.raycaster.intersectObjects(clickableObjects, true).slice(0, 1);
         
  if (intersects.length > 0) {
    const selected = intersects[0].object
    console.log('选中模型:', selected)
  }
}

ok,转化为普通 数据之后就会不出现性能警告的问题。

为什么 Vue3Proxy 会造成性能问题了?

这里我们点开一个模型的数据结构层级就知道了

这里我们可以看到three.js 解析出来的模型数据结构是非常复杂,每一次属性访问(例如 .position.x)都会触发 Vue 的依赖追踪 (track),

而 three.js 的 raycaster.intersectObjects() 内部会遍历上千次对象属性!

结果就是:
VueProxy 在每次属性访问时都执行大量 响应式追踪 开销,

导致点击检测的时间从几毫秒上百毫秒

三、scene.toJSON() 导出整个场景内容,文件体积特别大?(Base64优化方案)

如果你的项目涉及对多个3D模型属 性内容进行大量编辑保存 ,那么 .toJSON() 的数据存储方案或许会是一个不错的选择

但是细心的你会发现你的场景中只有一个1-10M 大小的模型,但是导出 .json 文件后大小可能是场景中模型的两倍甚至更大,这对于服务器的存储压力将是巨大的。

加载一个 7M 左右的模型

通过 scene.toJSON() 将场景内容导出 .json 文件,有 24M 左右

为什么导出 .json 文件后体积会很大?这里我们查看一下 toJSON() 后的数据格式

然后查看 images 中数据格式

这里我们发现场景中模型贴图资源被转化为了base64 格式了,这就是直接导致场景导出 .json 后体积很大的原因了

解决方案:在导出的时候重写场景中所有模型材质贴图的大小

这里我们封装两个方法去实现

方法一:processSceneTextures 用于遍历循环场景中所有的模型材质

方法二:resizeTexture 调整传入的贴图大小

js 复制代码
/**
 * 调整贴图大小
 * @param texture 原始贴图
 * @param proportion 缩放比例,默认0.5(即缩小到原来的50%)
 * @returns 调整后的贴图
 */
export function resizeTexture(
  texture: THREE.Texture,
  proportion = 0.5
): THREE.Texture {
  const image = texture.image as
    | HTMLImageElement
    | HTMLCanvasElement
    | HTMLVideoElement
    | ImageBitmap
    | null;
  if (!image) return texture;

  // 如果比例为1,直接返回原贴图
  if (proportion === 1) return texture;

  const canvas = document.createElement('canvas');

  // 根据比例计算新的宽高
  const width = Math.round(image.width * proportion);
  const height = Math.round(image.height * proportion);
  console.log(width, height);
  canvas.width = width ? width : image.width;
  canvas.height = height ? height : image.height;
  const ctx = canvas.getContext('2d');
  if (ctx) {
    // 设置高质量渲染
    ctx.imageSmoothingEnabled = true;
    ctx.imageSmoothingQuality = 'high';

    ctx.drawImage(image, 0, 0, width, height);
    const resizedTexture = new THREE.Texture(canvas);
    resizedTexture.needsUpdate = true;

    // 保持原贴图的所有属性
    resizedTexture.minFilter = texture.minFilter;
    resizedTexture.magFilter = texture.magFilter;
    resizedTexture.wrapS = texture.wrapS;
    resizedTexture.wrapT = texture.wrapT;
    resizedTexture.generateMipmaps = texture.generateMipmaps;
    resizedTexture.colorSpace = texture.colorSpace;
    resizedTexture.format = texture.format;
    resizedTexture.type = texture.type;
    resizedTexture.anisotropy = texture.anisotropy;
    resizedTexture.flipY = texture.flipY;
    resizedTexture.premultiplyAlpha = texture.premultiplyAlpha;
    resizedTexture.unpackAlignment = texture.unpackAlignment;
    resizedTexture.repeat.set(texture.repeat.x, texture.repeat.y);
    resizedTexture.offset.set(texture.offset.x, texture.offset.y);
    resizedTexture.rotation = texture.rotation;
    resizedTexture.center.set(texture.center.x, texture.center.y);
    resizedTexture.matrixAutoUpdate = texture.matrixAutoUpdate;
    resizedTexture.matrix.copy(texture.matrix);
    resizedTexture.matrixAutoUpdate = texture.matrixAutoUpdate;
    return resizedTexture;
  }

  return texture;
}


/**
 * 处理场景中所有的贴图内容
 * @param scene 场景对象
 * @param proportion 缩放比例,默认0.5(即缩小到原来的50%)
 * @returns 处理后的贴图数量
 */
export function processSceneTextures(
  scene: THREE.Scene,
  proportion = 0.5
): void {
  scene.traverse((object: THREE.Object3D) => {
    // 处理网格对象的材质
    if (object instanceof THREE.Mesh && object.material) {
      const materials = Array.isArray(object.material)
        ? object.material
        : [object.material];
      materials.forEach((material: THREE.Material) => {
        // 遍历材质的所有属性,查找贴图
        Object.values(material).forEach((value) => {
          if (value instanceof THREE.Texture) {
            // 检查贴图是否需要处理(避免重复处理)
            const image = value.image as
              | HTMLImageElement
              | HTMLCanvasElement
              | HTMLVideoElement
              | ImageBitmap
              | null;
            if (!image) return;

            // 跳过视频相关的内容
            if (image instanceof HTMLVideoElement) return;
              // 处理贴图大小
              const resizedTexture = resizeTexture(value, proportion);
              // 如果贴图被调整了大小,替换原贴图
              if (resizedTexture !== value) {
                // 释放原贴图
                value.dispose();
                // 更新材质中的贴图引用
                Object.keys(material).forEach((key) => {
                  const materialObj = material as unknown as Record<
                    string,
                    unknown
                  >;
                  if (materialObj[key] === value) {
                    materialObj[key] = resizedTexture;
                  }
                });
              }
         
          }
        });
      });
    }
  });
}

这里我们将材质贴图大小减小一半看看导出的效果

js 复制代码
import { processSceneTextures } from '@/utils/utils';

// 导出场景.josn
const exportJson =()=>{
      processSceneTextures(newScene,0.5);
      const jsonData = {
        scene: newScene?.toJSON(),
        camera:camera?.toJSON(),
      };
      const blob = new Blob([JSON.stringify(jsonData)], {
        type: 'application/json',
      });
      const url = URL.createObjectURL(blob);
      const link = document.createElement('a');
      document.body.appendChild(link);
      link.href = url;
      link.download = `${new Date().toLocaleString()}.json`;
      link.click();
      document.body.removeChild(link);
      URL.revokeObjectURL(url);
}

优化后导出的大小是 6M 左右

四、scene.toJSON() 导出整个场景内容,文件体积特别大?(jszip)优化方案

在使用优化图片资源base64 的方法后你会发现如果我们将图片缩放的太小 ,那么模型贴图在场景中展示就会变得很模糊,如果你对模型显示效果有着较高要求那么优化贴图资源base64大小的方法很显然不适合。

同时如果一个模型的顶点,三角形数量太多也会造成.json文件体积特别大

解决方案:使用 jszip 压缩插件将资源压缩处理

github.com/Stuk/jszip

安装:

js 复制代码
pnpm add jszip
pnpm add -D @types/jszip

1.这里将压缩方法封装一下 exportSceneWithJSZip

js 复制代码
import * as THREE from 'three';
import JSZip from 'jszip';

async function exportSceneWithJSZip(scene: THREE.Scene) {
  const sceneJson = scene.toJSON();
  // 拆出较大的字段(可选拆分几何、材质、贴图等)
  const { geometries, materials, images, ...rest } = sceneJson;

  //  构建多个文件数据
  const files: { name: string; content: string | ArrayBuffer | Blob }[] = [];
  // 主场景结构(不含大资源)
  files.push({
    name: 'scene.json',
    content: JSON.stringify(rest),
  });

  // 子资源
  if (geometries) {
    files.push({
      name: 'geometries.json',
      content: JSON.stringify(geometries),
    });
  }
  if (materials) {
    files.push({
      name: 'materials.json',
      content: JSON.stringify(materials),
    });
  }
  if (images) {
    files.push({
      name: 'images.json',
      content: JSON.stringify(images),
    });
  }

  const zip = new JSZip();
  for (const f of files) {
    zip.file(f.name, f.content);
  }

  const blob: Blob = await zip.generateAsync({
    type: 'blob',
    compression: 'DEFLATE',
    compressionOptions: {
        level: 9, // 采用最高压缩等级
      },
    // 你可以打开下面这个 callback 来监听进度
    onUpdate: (meta) => {
      console.log(`压缩进度 ${meta.percent.toFixed(2)}%`, meta.currentFile);
    }
  });
  return blob; // 你可以用这个 blob 下载或上传
}
  1. 通过浏览器导出下载.zip文件,
js 复制代码
// 下载.zip
function downloadBlob(blob: Blob, filename: string) {
  const url = URL.createObjectURL(blob);
  const a = document.createElement('a');
  a.href = url;
  a.download = filename;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
  URL.revokeObjectURL(url);
}

async function testExport(scene: THREE.Scene) {
  const blob = await exportSceneWithJSZip(scene);
  downloadBlob(blob, `场景资源${new Date().toLocaleString()}.zip`);
}

3.如果你不想通过浏览器下载.zip而是直接上传服务端,也可以这样去实现

js 复制代码
async function testExport(scene: THREE.Scene) {
  const blob = await exportSceneWithJSZip(scene);
  
  const form = new FormData();
  // 把 blob 作为一个文件字段上传
  form.append('file', blob, filename);
  
  const resp = await fetch('https:xxxxx你的后端接口地址', {
    method: 'POST',
    body: form,
  });
}

这里我们对比一下,使用jszip 压缩前后导出的文件体积大小

使用一个14M大小的模型测试一下导出后的资源大小

压缩的资源大小(166M):

压缩的资源大小(24M):

看到这里你可能会有疑问了?

为什么 14M 大小的模型导出.json 后会有 166M?

这里我们通过 three.js 编辑器查看一下这个模型的内容结构

这里我们可以看到这个模型的顶点数量是:2,263,688 三角形数量是:758,971

因为:在通过three.js 的 toJSON() 方法将场景数据内容转化为 json 时,除了贴图资源的base64 会占用了较大的字节 资源以外,每个顶点会有x,y,z 坐标 + 法线 + UV + 可能的颜色、骨骼权重等,在转化为json 字符串时自然会占用更多的字节空间了

虽然压缩后的资源大小仍然有24M,但这个大小已经是服务端可以接受的范围了

压缩后的.zip 资源如何解析出来重新加载到场景中去了?

这个也很简单 jszip 和 three.js 都提供了对应的资源解析方法

jszip 通过 JSZip.loadAsync() 方法将文件内容解析出来

three.js 通过 new ObjectLoader().parseAsync(scene) 方法将.json 文件解析成可识别的场景数据内容

js 复制代码
import JSZip from 'jszip';
import * as THREE from 'three';

async function importScene(zipBlob: Blob) {
  const zip = await JSZip.loadAsync(zipBlob);
  // 读取并解析 scene.json
  const sceneJsonString = await zip.file('scene.json')?.async('string');
  if (!sceneJsonString) {
    throw new Error('scene.json 不存在');
  }
  const sceneData = JSON.parse(sceneJsonString);
  //  通过 THREE.ObjectLoader 重新解析为 three.js 对象
  const loader = new THREE.ObjectLoader();
  const newScene = loader.parseAsync(sceneData);
  scene= newScene
}

五、避免使用 scene.traverse 遍历内容

three.js 提供了traverse 这种遍历整个场景内容的方法,traverse 的应用场景多数用于我们想要在场景中找到某一个内容,又或者想要批量查找和修改场景中的多个内容时,我们就会使用traverse

但是 traverse 属于深度遍历(递归)整个场景树,包括所有子层级对象(孙级、曾孙级等),深度遍历的代价就意味着有更高的性能消耗,这种情况在你的场景内容非常多的时候就会特别明显

为了更直观的显示 traverse 遍历带来的性能消耗

这里打印一下,一个空场景时 scene.traversescene?.children.forEach 会有哪些内容打印

scene.traverse

scene?.children.forEach

推荐:

1.如果你只是想找到场景中的某一个值,完全可以使用 getObjectByProperty 或者getObjectByName 去实现

js 复制代码
const obj = scene.getObjectByProperty('uuid', targetUuid);
if (obj) {
  console.log('找到对象', obj);
}
const mesh = scene.getObjectByName('MyMesh');
if (mesh) {
  console.log('找到对象', obj);
}

而不是通过 traverse

js 复制代码
scene.traverse(obj => {
  if (obj.name === 'MyMesh') {
    console.log('找到对象', obj);
  }
});

2.如果你需要同时去获取场景中的多个内容则可以使用 数组Array相关的方法去遍历 scene.children

使用scene.children.filter 找到场景中带有动画的模型

js 复制代码
 const animationObjectList =* *scene?.children.filter((object) => object.animations.length > 0);  
         

使用 scene.children.forEach 修改场景中类型的位置

js 复制代码
scene.children.forEach((child) => {
  // 确保是 Mesh
  if (child instanceof THREE.Points) {
    // 修改 position
    child.position.set(Math.random() * 5, Math.random() * 5, Math.random() * 5);
    console.log(`修改了 ${child.name} 的位置为`, child.position);
  }
});

当然在实现像复制删除 这些功能时为了将所有内容复制和资源的彻底释放,这时候就必须使用traverse来实现了

js 复制代码
       const disposeMaterialAndRemove = () => {
          if (node.children) {
            // 遍历并释放所有子网格的材质
            material.traverse((child: THREE.Object3D) => {
              if (child instanceof THREE.Mesh && child.material) {
              // 确保被删除的内容资源释放
                child.material.dispose();
              }
            });
            this.scene?.remove(material);
          } else {
            material.parent?.remove(material);
          }
        };

六、正确的释放three.js内存资源(dispose)

场景 :在涉及删除替换 模型以及替换材质贴图时,一定要将删除或者替换内容的资源手动进行释放出来

:为什么我没有主动释放资源依然不会存在性能问题?

:可能你的three.js项目只是涉及简单 的模型切换展示再加上现代浏览器内核 的成熟化以及电脑设备性能较好,你可能感觉不到有任何的内存和性能问题。

但是一旦涉及到多模型编辑这种场景时,你的场景操作越频繁,性能问题就会逐渐暴露出来了

为什么需要手动释放资源?这里可以直接参考three.js 官方给出的答案

  1. 针对模型的删除我们可以专门封装一个方法来释放资源,three.js 内部提供了一个释放资源的方法 dispose() 这里直接调用就可以了
js 复制代码
/**
 * 释放材质资源
 * @param THREE.Mesh | THREE.Material | THREE.Material[] - 要释放的材质对象
 */
export const disposeMaterial = (
  material: THREE.Mesh | THREE.Material | THREE.Material[]
): void => {
  if (!material) return;
  const disposeSingleMaterial = (mat: THREE.Material) => {
    // 释放纹理
    Object.values(mat).forEach((value) => {
      if (value instanceof THREE.Texture) {
        value.dispose();
      }
    });
    // 释放 uniforms
    const materialWithUniforms = mat as MaterialWithUniforms;
    if (materialWithUniforms.uniforms) {
      Object.values(materialWithUniforms.uniforms).forEach((uniform) => {
        if (uniform?.value?.dispose) {
          uniform.value.dispose();
        }
      });
    }
    // 释放材质本身
    mat.dispose();
  };
  if (material instanceof THREE.Mesh && material.material) {
    // 处理网格对象的材质
    if (Array.isArray(material.material)) {
      material.material.forEach(disposeSingleMaterial);
    } else {
      disposeSingleMaterial(material.material);
    }
  } else if (material instanceof THREE.Material) {
    // 直接处理材质对象
    disposeSingleMaterial(material);
  } else if (Array.isArray(material)) {
    // 处理材质数组
    material.forEach(disposeSingleMaterial);
  }
};

然后这样调用就可以了

js 复制代码
scene.remove(mesh);
disposeMaterial(mesh)
  1. 针对贴图资源的替换我们可以这样
js 复制代码
const oldTexture = material.map;
material.map = new THREE.TextureLoader().load('new.png');
oldTexture.dispose();

3.如果涉及销毁和重新创建画布,也需要将整个场景资源进行释放

js 复制代码
   scene?.clear();
   renderer?.dispose();

七、本地项目开发,vite热更新导致性能问题

在使用当今主流的两个框架 Vue3/React时,我们大部分都会使用 Vite 来作为构建工具

Vite 的局部热更新不仅能提高我们的开发效率还能提高开发的体验

同时在我们项目本地开发调试时也需要注意一些性能问题

问题 :当我在修改代码后就会触发vite 的热更新,同时也会触发 Vue 的 生命周期比如:onMounted , onUnmounted

这时候如果我们在onMounted添加了一下 three.js 和浏览器的监听方法比如:

three.js 变换控制器的监听方法和浏览器窗口大小变换的方法时

js 复制代码
transformControls.addEventListener('dragging-changed',()=>console.log('dragging-changed'))

transformControls.addEventListener('change',()=>console.log('change'))

 window.addEventListener('resize',()=>console.log('WindowResizes'))

如果没有在 onUnmounted 去执行移除监听的方法,那么随着你的热更新次数越多,你的监听事件也会被添加的越多。

同时vite 更新也会导致 创建three.js 场景内容等方法重新执行

所以为了保证本地开发有一个好的性能体验,也需要进行单独处理这里我们单独封装一个方法用于移除监听和释放场景资源的方法

js 复制代码
 renderDestroy() {
    // 取消动画循环
    if (this.renderAnimation) {
      cancelAnimationFrame(this.renderAnimation);
      this.renderAnimation = null;
    }
    disposeScene(this.scene);
    TWEEN.removeAll();
    // TWEEN.removeAll()清理场景
    this.scene?.clear();
    // 释放控制器
    if (this.controls) {
      this.controls.dispose();
      this.controls = null;
    }
    // 移除事件监听器
    if (this.onWindowResizesListener) {
      window.removeEventListener('resize', this.onWindowResizesListener);
      this.onWindowResizesListener = null;
    }
    // 释放变换控制器
      this.transformControls?.removeEventListener(
        'dragging-changed',
        this.draggingChangedHandler
      );

      this.transformControls?.removeEventListener(
        'change',
        this.transformChangeHandler
      );
 
    // 释放 ViewHelper
    if (this.viewHelper) {
      this.viewHelper.dispose();
      this.viewHelper = null;
    }
   this.renderer?.dispose();
    // 清空其他引用
    this.camera = null;
    this.scene = null;
    this.container = null;
  }
js 复制代码
onUnmounted(() => {
  renderDestroy();
});

大概逻辑思路就是参考上面代码的实现方式就行了,同时这个方法也能解决three.js在Vue3这种项目中当页面离开时,3D场景资源没有被正确释放的问题

八、 WebWorker 处理主线程阻塞

如果你的场景内容资源很大 那么无论是导出还是加载,必然会造成主线程的阻塞,这里建议使用 woker单独开辟一个线程去解决这个问题

js 复制代码
// 创建 Worker(模块)
const worker =  new Worker(new URL('./exportWorker.ts', import.meta.url), {
      type: 'module',
});
worker.onmessage = onWorkerMessage;
worker.onerror = (e) => { console.error('Worker error', e); log('Worker 报错,见控制台'); };
const onWorkerMessage =(event)=>{
           const { type, progress, data, error } = event.data as {
            type: string;
            progress?: number;
            error?: string;
          };
          switch (type) {
            case 'progress':
              console.log('当前进度'+progress)
              break;
            case 'complete':
              console.log('执行完成')
              break;
            case 'error': 
              console.log('错误情况')
              break;
             default:
              break;
          }
}
// 发送导出任务到 Worker,传递数据
  worker.postMessage({
      type: 'export',
      data: file,
   });

exportWorker.js 收到export类型消息后开始执行任务

js 复制代码
self.onmessage = async (event: MessageEvent) => {
  const messageData = event.data as { type: string; data: unknown };
  const type: string = messageData.type;
  const data: unknown = messageData.data;

  if (type === 'export') {
    // 从主线程传递的数据中获取文件列表
    const files = data as Array<{name: string, data: any}>;
    try {
      // 重置进度并开始处理
      self.postMessage({
        type: 'progress',
        progress: 0,
        data: { message: '开始处理场景数据...' },
      });
      const zipBlob = await processChunkedCompression(
        files,
        (progress: number) => {
          let message = '正在压缩文件... ' + progress + '%';
          if (progress === 100) {
            message = '资源压缩完成,正在执行保存操作请稍等...';
          }
          self.postMessage({
            type: 'progress',
            progress,
            data: { message },
          });
        }
      );
      // 完成处理
      self.postMessage({
        type: 'complete',
        data: {
          blob: zipBlob,
          size: zipBlob.size,
          message: '场景导出完成',
        },
      });
    } catch (error) {
      console.error('Worker 处理出错:', error);
      self.postMessage({
        type: 'error',
        error: error instanceof Error ? error.message : '未知错误',
      });
    }
  }
};

通过 worker 去优化处理大场景加载和导出的情况,这样你的页面就会不卡死了

一些作者在three.js开发中用到的小技巧,希望对你有帮助。

1、THREE.MathUtils.generateUUID()的使用

通过THREE.MathUtils.generateUUID() 可以创建一个和 three.js uuid 一样格式的唯一标识,在 three.js 如果你需要给一个模型绑定一个唯一不变的值时,可以使用generateUUID就避免单独封装一个方法

js 复制代码
   const onlyUuid = THREE.MathUtils.generateUUID();
    mesh.userData = {
      // 生成一个唯一 id,解决模型每次加载后uuid 变化问题
      onlyUuid,
    };

2、 鼠标点击选中整个模型?

每个子类都一个parent 属性,通过while 循环就可以获取最顶层的父级,从而实现点击模型的某个部位选中整个模型

js 复制代码
/**
 * 获取最顶层的 parent 对象
 * @param object - 当前对象
 * @returns 最顶层的 parent 对象,如果找不到则返回原对象
 */
export const getTopLevelParent = (object: THREE.Object3D): THREE.Object3D => {
  if (!object || !object.parent) {
    return object;
  }
  let currentObject = object;
  let topLevelParent = object;
  // 向上遍历父级对象,直到找到最顶层的非scene对象
  while (currentObject.parent) {
    // 如果父级是scene,则停止遍历
    if (currentObject.parent.type === 'Scene') {
      break;
    }
    // 更新最顶层parent
    topLevelParent = currentObject.parent;
    currentObject = currentObject.parent;
  }
  return topLevelParent;
};

3、traverseVisible 遍历场景中可见的内容

traverseVisible 只会遍历 visible=true 的内容

js 复制代码
scene.traverseVisible(obj => {
  if (obj.isMesh) {
    // 仅处理可见 Mesh
    console.log(obj)
  }
});

4、场景(environment+hdr )发光替代灯光(light

three.js 中光源是一个非常重要的内容,如果你的材质没有自发光属性则在场景中会是黑色的显示状态

如果你的场景内容很多,又不想使用多个灯光时可以考虑使用scene.environment +hdr 去代替 light

从而照亮整个场景

js 复制代码
 async initScene(): Promise<void> {
    this.scene = new THREE.Scene();
    const hdrLoader = new HDRLoader();
    const texture = await hdrLoader.loadAsync('hdr/view-hdr-1.hdr');
    texture.mapping = THREE.EquirectangularReflectionMapping;
    this.scene.background = new THREE.Color('#aaaaaa');
    this.scene.environment = texture as unknown as THREE.Texture;
    // 调整环境光强度;
    this.scene.backgroundIntensity = 1;
    return Promise.resolve();
  }

5、视频和图片纹理贴图有明显的色差?

如果你选择了一张图片 或者一个视频作为材质的贴图,但你发现图片或者视频在three.js 场景中的展示修改和图片视频的实际效果有很明显的色差时

请将纹理的 colorSpace 值设置为 THREE.SRGBColorSpace 即可

js 复制代码
    const video = document.createElement('video');
    video.crossOrigin = 'anonymous';
    video.loop = loop;
    video.muted = true; // 默认静音,避免自动播放限制
    const texture = new THREE.VideoTexture(video);
    texture.minFilter = THREE.LinearFilter;
    texture.magFilter = THREE.LinearFilter;
    texture.colorSpace = THREE.SRGBColorSpace; // 设置正确的颜色空间,确保颜色准确

6、贴图太小材质内容太长导致贴图有拉伸?

如图片所示

通过设置纹理的repeat属性来设置重复次数来解决

js 复制代码
texture.repeat.set(15, 15);

7、通过 userData 储存业务数据

如果你的项目不仅仅是简单展示一个模型,而是有更复杂的需求,那么我比较推荐你将需要用到的业务数据或者一些逻辑判断值存储在模型的 userData 属性中,这样使用three.js结合你的业务逻辑实现起来更加轻松

比如给一个几何体模型添加自定义数据

js 复制代码
 //  创建几何体(立方体)
const geometry = new THREE.BoxGeometry(1, 1, 1)
// 创建材质
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 })
//  组合成网格对象
const cube = new THREE.Mesh(geometry, material)

cube.userData ={
  isTransformControls: true,
  type: 'Geometry',
  onlyUuid: THREE.MathUtils.generateUUID(),
}

这样如果我们需要找到场景中所有的几何体内容时就可以通过child.userData.type这样实现

js 复制代码
 const gemotryList = scene.children.filter((child)=>{child.userData.type==="Geometry"})

或者在实现点击选中功能时通过 userData.isTransformControls,获取到可以被点击的内容过滤不必要的数据优化性能

js 复制代码
    const rect = container.getBoundingClientRect();
    const currentPosition = new THREE.Vector2(
      event.clientX - rect.left,
      event.clientY - rect.top
    );
    this.mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
    this.mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
    this.raycaster.setFromCamera(this.mouse, camera);

    const clickableObjects = Array.from(scene?.children || [])
      .map((obj) => toRaw(obj))
      .filter(
        (obj) => obj.userData?.isTransformControls
      );

    const intersects = this.raycaster
      .intersectObjects(clickableObjects, true)
      .slice(0, 1);

8、按需渲染

three.js 中我们通过 requestAnimationFrame 动画帧去连续渲染去实现场景画布内容的更新

js 复制代码
 sceneAnimation(): void {
     // 确保动画循环持续进行
     this.renderAnimation = requestAnimationFrame(() => this.sceneAnimation());
      // 更新 TWEEN
      TWEEN.update();
      this.controls.update();
      // 更新包围盒
      this.boxHelper?.update();
      // 渲染场景
      this.renderer.render(this.scene, this.camera);
      // 更新第一人称控制器
      this.updatePointerLockControls();
  }

但是有些内容是不需要 实时更新的,而是在某种状态开启后才需要连续的渲染

这时候我们可以添加一个判断逻辑,避免不必要的内容更新

js 复制代码
  /**
   * 场景动画循环
   */
  sceneAnimation(): void {
    // 确保动画循环持续进行
    this.renderAnimation = requestAnimationFrame(() => this.sceneAnimation());
    if (this.loadingStatus || this.controls.enabled) {
      // 更新 TWEEN
      TWEEN.update();
      // 更新控制器 如果当前是第一人称控制器则不更新
      if (!this.pointerLockControls) {
        this.controls.update();
      }
      // 更新包围盒
      if(this.boxHelper.visible)this.boxHelper?.update();
      // 渲染场景
      this.renderer.render(this.scene, this.camera);
      // 更新第一人称控制器
      if (this.pointerLockControls) {
        this.updatePointerLockControls();
      }
    }
  }

9、 THREE.Color 使用

THREE.Color 提供了将three.js color 转换成不同格式的 css值 的方法

js 复制代码
const color = new THREE.Color(threeColor);
const cssColor = color.getStyle();
const hexColor = color.getHexString();
const hexNumber = color.getHex();

结语

ok,以上就是作者个人在使用three.js 开发项目时遇到的性能问题和three.js 技巧。如果你也遇到和作者不一样的性能相关问题,欢迎留言沟通。

相关推荐
我的div丢了肿么办3 小时前
vue3使用h函数如何封装组件和$attrs和props的区别
前端·javascript·vue.js
自由的疯3 小时前
java调chrome浏览器显示网页
java·前端·后端
小白而已3 小时前
协程&挂起&恢复
前端
Mintopia3 小时前
单体 vs 微服务:当 Next.js 长成“巨石阵”以后 🪨➡️🧩
前端·后端·全栈
吃饺子不吃馅3 小时前
大家都在找的手绘/素描风格图编辑器它它它来了
前端·javascript·css
陈随易3 小时前
改变世界的编程语言MoonBit:配置系统介绍(上)
前端·后端·程序员
Zhencode3 小时前
CSS变量的应用
前端·css
Mintopia3 小时前
AIGC 训练数据的隐私保护技术:联邦学习在 Web 场景的落地
前端·javascript·aigc
鹏多多3 小时前
React项目集成苹果登录react-apple-signin-auth插件手把手指南
前端·javascript·react.js