Skip to content

基本概念和原理 📚 ​

首先我们了解一些基本概念

体素:体积像素,类似于二维平面的像素点,体素就是一种三维的像素点,体素化是将三维物体转化为体素的过程。你可以把体素理解成一块一块的积木,体素模型就是用积木拼起来的模型

现在我们有这样一个需求:我们需要通过拖拽/上传,把用户提供的模型文件能够渲染成体素模型,这该怎么做呢?

我们可以把整个需求分解成下面几个小步骤:

  1. 实现上传/拖拽文件功能 📤
  2. File --> Model 实现渲染模型功能 🎨
  3. 将模型体素化,渲染体素模型 🎮
  4. 优化... ⚡

实现上传/拖拽文件功能 📤 ​

我们先来实现简易版的 模块引入 & 上传 & 拖拽上传文件功能

注意点:

  1. cdn 引入threejs,使用importmap引入 自己可以修改对应的version
js
  <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.126.1/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.126.1/examples/jsm/"
      }
    }
    </script>
  1. drag事件要阻止默认事件,否则会触发浏览器的默认行为(打开文件)

  2. handleFile2Model之后需要清空input的value值,不然通过点击上传同名文件的时候,不会触发change事件

完整代码:

html
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <style>
    .inputBox {
      margin: 0 auto;
      display: flex;
      align-items: center;
      justify-content: center;
      width: 500px;
      height: 500px;
      border: 1px solid rgb(0, 0, 0);
      border-radius: 16px;
    }
  </style>
</head>

<body>
  <div class="inputBox">
    Upload
    <input type="file" id="file" accept=".glb,.gltf,.fbx,.obj,.dae,.ply,.stl" style="display: none;">
  </div>
  <script type="importmap">
    {
      "imports": {
        "three": "https://cdn.jsdelivr.net/npm/three@0.126.1/build/three.module.js",
        "three/addons/": "https://cdn.jsdelivr.net/npm/three@0.126.1/examples/jsm/"
      }
    }
    </script>
  <script type="module">
    import * as THREE from 'three';
    import { OrbitControls} from 'three/addons/controls/OrbitControls.js';
    import { GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';


    const uploadWrapper = document.querySelector('.inputBox');
    const input = document.querySelector('#file');

    uploadWrapper.addEventListener('click', () => {
      input.click();
    });

    uploadWrapper.addEventListener('dragover', (e) => {
      e.preventDefault(); 
    });

    uploadWrapper.addEventListener('drop', (e) => {
      e.preventDefault();
      const files = e.dataTransfer.files;
      handleFile2Model(files[0])
    });

    input.addEventListener('change', (e)=>{
      const files = e.target.files;
      handleFile2Model(files[0])
    });

    function handleFile2Model(file){
      console.log(file);
      //file 2 model
      input.value = '' // 不然再次上传同名文件不会触发change事件
    }
  </script>
</body>

</html>

示例图:

实现渲染模型功能 🎨 ​

接下来我们就需要开始把file转换成模型渲染出来了,在这里我们需要先搭建一个简易的Three Scene,然后通过Loader加载模型,

注意点:

  1. 'gltf', 'glb', 'dae'的Loader渲染得到的在object.scene中,其他的都是object

  2. 通过traverse可以统一调整所有Mesh的位置,使所有模型能够统一到center的位置

  3. 归一化处理是模型的大小在[-1,1]之间,否则会出现模型大小不一致的情况

js
 function handleFile2Model(file) {
     console.log(file);
     mainScene.loadModel(file)
     input.value = '' // 不然再次上传同名文件不会触发change事件
   }


   class Scene {
     constructor(renderTarget) {
       this.scene = new THREE.Scene();
       this.camera = null;
       this.renderer = null;
       this.controls = null;
       this.renderTarget = renderTarget; // 渲染目标
       this.voxelsMap = []
       this.voxels = []
       this.fileLoaderMap = {
         'glb': new GLTFLoader(),
         'fbx': new FBXLoader(),
         'gltf': new GLTFLoader(),
         'obj': new OBJLoader(),
         'dae': new ColladaLoader(),
         'stl': new STLLoader(),
         'ply': new PLYLoader(),
       }

       this.initRenderer();
       this.initCamera();
       this.initControls();
       this.initLights();
       this.initGridHelper();
       this.render();
       window.addEventListener('resize', this.onWindowResize.bind(this), false);
     }

     initRenderer() {
       this.renderer = new THREE.WebGLRenderer({ antialias: true });
       this.renderer.setSize(this.renderTarget.clientWidth, this.renderTarget.clientHeight);
       this.renderer.setPixelRatio(window.devicePixelRatio);
       this.renderer.setClearColor(0x444444, 1);
       this.renderTarget.appendChild(this.renderer.domElement);
     }

     initCamera() {
       this.camera = new THREE.PerspectiveCamera(30, this.renderTarget.clientWidth / this.renderTarget.clientHeight, 1, 3000);
       this.camera.position.set(3, 3, 3);//model加载完之后可以根据模型更新位置
     }

     initControls() {
       this.controls = new OrbitControls(this.camera, this.renderer.domElement);
       this.controls.enableDamping = true;
       this.controls.dampingFactor = 0.1;
       this.controls.minDistance = 5;//model加载完之后可以根据模型更新位置
       this.controls.maxDistance = 20;
       this.controls.addEventListener('change', () => {
         this.renderer.render(this.scene, this.camera);
       });
       this.controls.zoomSpeed = 0.8;
     }

     initLights() {
       const ambientLight = new THREE.AmbientLight(0xffffff, 0.6);
       this.scene.add(ambientLight);

       const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
       directionalLight.position.set(100, 200, 50);
       this.scene.add(directionalLight);
     }

     initGridHelper() {
       const size = 2000;
       const divisions = 10;
       const gridHelper = new THREE.GridHelper(size, divisions, 0xCCCCCC, 0xCCCCCC);
       gridHelper.material.opacity = 0.2;
       gridHelper.material.transparent = true;
       gridHelper.name = 'gridHelper'
       this.scene.add(gridHelper);

     }

     onWindowResize() {
       this.camera.aspect = this.renderTarget.clientWidth / this.renderTarget.clientHeight;
       this.camera.updateProjectionMatrix();
       this.renderer.setSize(this.renderTarget.clientWidth, this.renderTarget.clientHeight);
       this.renderer.render(this.scene, this.camera);
     }

     render() {
       requestAnimationFrame(this.render.bind(this));
       this.controls.update();
       this.renderer.render(this.scene, this.camera);
     }

     async loadModel(file) {
       console.log('--LOAD START--');
       return new Promise((resolve, reject) => {
         const reader = new FileReader();
         reader.onload = (event) => {
           const voxelName = file.name
           const extension = voxelName.split('.').pop().toLowerCase();
           this.voxelsMap.push({ name: voxelName, voxels: [] }) //留作缓存以及记录动画voxels数组
           let loader = this.fileLoaderMap[extension]
           if (!loader) {
             console.error('Unsupported file format.');
             return;
           }
           loader.load(event.target.result, (object) => {
             this.clearModel()  //清除之前的模型
             let scaleFactor
             //这里注意不同格式拿到的模型场景所在位置不一样
             let tempScene = ['gltf', 'glb', 'dae'].includes(extension) ? object.scene : object;

             // 获取 tempScene 的中心点
             const boundingBox = new THREE.Box3().setFromObject(tempScene);
             const center = new THREE.Vector3();
             boundingBox.getCenter(center);

             // 遍历 tempScene中 的所有Mesh,将所有Mesh的位置调整以使中心点在原点
             const meshGroup = new THREE.Group();
             tempScene.traverse((object) => {
               if (object.isMesh) {
                 object.position.sub(center);
                 meshGroup.add(object.clone());  //不使用clone maybe会出现Cannot read properties of undefined (reading 'traverse')
               }
             });

             //归一化处理
             scaleFactor = this.normalizeAndVoxelize(meshGroup)
             meshGroup.scale.set(scaleFactor, scaleFactor, scaleFactor)

             // 添加到场景中
             this.showMesh = meshGroup
             this.scene.add(this.showMesh);
             this.showMesh.visible = true
             console.log('--LOAD END--');

             //体素化
             // this.generateDepthTexturePNG(this.showMesh).then(depthMaps => {
             //   this.depthMap2Pos(depthMaps, voxelName)
             // });

             resolve(voxelName)
           }, () => {

           }, (err) => {
             console.error('Failed to load object.', err);
             reject()
           });

         };
         reader.readAsDataURL(file);
       })
     }

     normalizeAndVoxelize(scene) {
       let scaleFactor = 1;
       const boundingBox = new THREE.Box3().setFromObject(scene);
       if (!boundingBox.isEmpty()) {
         const maxDimension = Math.max(
           boundingBox.max.x - boundingBox.min.x,
           boundingBox.max.y - boundingBox.min.y,
           boundingBox.max.z - boundingBox.min.z
         );

         scaleFactor = 2 / maxDimension * 0.95;
         console.log('scaleFactor', scaleFactor);
       } else {
         console.error('Bounding box is empty.');
       }
       return scaleFactor
     }

     clearModel() {
       if (this.showMesh) {
         this.showMesh?.geometry?.dispose();
         this.showMesh?.material?.dispose();
         this.scene.remove(this.showMesh);
         this.showMesh = null;
       }

       if (this.instancedMesh) {
         this.instancedMesh?.geometry?.dispose();
         this.instancedMesh?.material?.dispose();
         this.scene.remove(this.instancedMesh);
         this.instancedMesh = null;
       }
       this.voxels = [];
     }
   }

   const mainScene = new Scene(uploadWrapper);

演示图:

Three.js体素化原理及实现 🔍 ​

在实现体素化的过程中,我们探索了两种方案:光线投射法和深度图法。让我们深入了解这两种方案的原理和实现。

方案一:光线投射法 🎯 ​

原理 ​

光线投射法的核心思想是:

  1. 获取模型的包围盒(Bounding Box)
  2. 将包围盒划分为网格
  3. 对每个网格点发射射线
  4. 通过射线与模型的相交次数判断点是否在模型内部

实现细节 ​

js
isInsideMesh(pos, target, mesh) {
  this.rayCaster.set(pos, target);
  const intersects = this.rayCaster.intersectObject(mesh);
  return intersects.length % 2 === 1; // 奇数次相交表示在内部
}

优缺点 ​

✅ 优点:

  • 实现直观易懂
  • 适用于各种模型

❌ 缺点:

  • 计算开销大
  • 对于薄壁模型精度不高
  • 需要多方向射线提高准确度

方案二:深度图法 🖼️ ​

原理 ​

深度图法的思路更加巧妙:

  1. 从六个正交方向获取模型的深度图
  2. 将深度信息转换为体素空间
  3. 通过深度值决定体素的存在与否

核心步骤详解 ​

1️⃣ 生成深度图

js
// 使用正交相机从六个方向拍摄
const directions = [
  { rotation: new THREE.Vector3(0, 0, 0), name: 'front' },
  { rotation: new THREE.Vector3(0, Math.PI, 0), name: 'back' },
  // ... 其他方向
];

// 使用自定义着色器渲染深度
const depthMaterial = new THREE.ShaderMaterial({
  vertexShader: `
    varying vec4 vWorldPosition;
    void main() {
      vWorldPosition = modelMatrix * vec4(position, 1.0);
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    varying vec4 vWorldPosition;
    void main() {
      float depth = vWorldPosition.z * 0.5 + 0.5;
      gl_FragColor = vec4(vec3(depth), 1.0);
    }
  `
});

2️⃣ 深度图转换为体素空间

js
// 将256x256的深度图降采样到16x16
const flippedPixels = new Uint8Array(16 * 16).fill(0);
for (let y = 0; y < 256; y++) {
  for (let x = 0; x < 256; x++) {
    const srcIndex = (y * 256 + x) * 4;
    const dstIndex = ((15 - Math.floor(y / 16)) * 16 + Math.floor(x / 16));
    flippedPixels[dstIndex] = Math.max(pixels[srcIndex], flippedPixels[dstIndex]);
  }
}

3️⃣ 构建体素模型

js
// 遍历体素空间创建实例化网格
for (let i = 0; i < 16; i++) {
  for (let j = 0; j < 16; j++) {
    for (let k = 0; k < 16; k++) {
      if (voxelsMappingPosArr[pos] === 1) {
        // 创建体素位置
        pos.set((i + 0.5) * voxelSize - 1, 
                (j + 0.5) * voxelSize - 1, 
                (k + 0.5) * voxelSize - 1);
        voxels.push({ position: pos.clone() });
      }
    }
  }
}

优化细节 ⚡ ​

  1. 使用WebGL RenderTarget高效获取深度图
  2. 采用InstancedMesh批量渲染体素
  3. 实现体素动画过渡效果
  4. 支持模型对称处理
  5. 设计缓存机制避免重复计算

性能对比 📊 ​

方案处理时间内存占用精确度
光线投射法5032ms中等较高
深度图法36ms低中等

优化效果: 时间从5032ms降至36ms,性能提升99%+ 🚀

总结与思考 🤔 ​

深度图法相比光线投射法有显著的性能优势,主要归功于:

  1. 降维处理:将3D问题转化为2D处理
  2. 批量计算:避免逐点射线检测
  3. GPU加速:利用WebGL渲染获取深度图

但深度图法也存在一些局限:

  1. 可能丢失细节特别是凹陷区域
  2. 分辨率受限于深度图大小
  3. 对于复杂形状可能需要更多视角

这次优化经历告诉我们:

  • 算法的选择往往需要在精度和效率之间权衡
  • 将3D问题降维处理可以带来显著的性能提升
  • GPU渲染能力是一个强大的计算工具

最后,感谢公司大佬的指点,让我重新认识到算法优化的重要性。正如这次优化经历所示,有时候需要换个思路,就能达到质的飞跃! 🌟