如何利用 Vue 结合 `Three.js` 或 `Babylon.js`,构建一个高性能的 3D 可视化应用?

各位老铁,大家好!今天咱们来聊聊如何用 Vue 这位前端小清新,去驾驭 Three.js 或 Babylon.js 这两位 3D 大佬,打造一个高性能的 3D 可视化应用。

开场白:Vue + 3D,天作之合?

有人可能会问,前端不是搞搞页面布局,写写交互逻辑吗?跟 3D 渲染这种高大上的东西能扯上关系?答案是肯定的!Vue 的组件化思想,数据驱动视图的特性,能很好地组织和管理 3D 场景中的各种元素。Three.js 和 Babylon.js 提供了强大的 3D 渲染能力,两者结合,能让我们在浏览器里轻松创建复杂的 3D 应用。

第一章:准备工作,磨刀不误砍柴工

在开始之前,我们需要准备一些工具和知识:

  • Vue CLI: Vue 的脚手架工具,用于快速搭建项目。
  • Node.js 和 npm (或 yarn): 用于安装和管理依赖包。
  • Three.js 或 Babylon.js: 3D 渲染引擎,二选一,看你喜欢哪个。
  • VS Code (或其他你喜欢的 IDE): 代码编辑器,提高开发效率。
  • 基本的 3D 概念: 比如场景、相机、光照、材质、几何体等等。

安装 Vue CLI:

npm install -g @vue/cli
# 或者
yarn global add @vue/cli

第二章:搭建项目,撸起袖子加油干

用 Vue CLI 创建一个新的项目:

vue create my-3d-app
# 选择 Vue 3
# 选择默认配置 (Use arrow keys)

进入项目目录,安装 Three.js (这里以 Three.js 为例,Babylon.js 的安装类似):

cd my-3d-app
npm install three
# 或者
yarn add three

第三章:第一个 3D 组件,Hello, 3D World!

创建一个名为 HelloWorld3D.vue 的组件:

<template>
  <div ref="container" style="width: 100%; height: 500px;"></div>
</template>

<script>
import * as THREE from 'three';

export default {
  mounted() {
    this.init();
    this.animate();
  },
  beforeUnmount() {
    this.dispose(); // 在组件卸载时进行清理
  },
  methods: {
    init() {
      // 1. 创建场景
      this.scene = new THREE.Scene();

      // 2. 创建相机
      this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      this.camera.position.z = 5;

      // 3. 创建渲染器
      this.renderer = new THREE.WebGLRenderer({ antialias: true });
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.$refs.container.appendChild(this.renderer.domElement);

      // 4. 创建一个立方体
      const geometry = new THREE.BoxGeometry(1, 1, 1);
      const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
      this.cube = new THREE.Mesh(geometry, material);
      this.scene.add(this.cube);

      // 5. 添加环境光
      const ambientLight = new THREE.AmbientLight(0x404040); // soft white light
      this.scene.add(ambientLight);

      // 6. 添加方向光
      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
      directionalLight.position.set(1, 1, 1);
      this.scene.add(directionalLight);

      // 7. 监听窗口大小变化
      window.addEventListener('resize', this.onWindowResize);
    },
    animate() {
      requestAnimationFrame(this.animate);

      this.cube.rotation.x += 0.01;
      this.cube.rotation.y += 0.01;

      this.renderer.render(this.scene, this.camera);
    },
    onWindowResize() {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
    },
    dispose() {
      // 清理资源,防止内存泄漏
      if (this.cube) {
        this.cube.geometry.dispose();
        this.cube.material.dispose();
      }
      if (this.renderer) {
        this.renderer.dispose();
      }
      window.removeEventListener('resize', this.onWindowResize);
    }
  }
};
</script>

App.vue 中引入并使用这个组件:

<template>
  <HelloWorld3D />
</template>

<script>
import HelloWorld3D from './components/HelloWorld3D.vue';

export default {
  components: {
    HelloWorld3D
  }
};
</script>

运行项目:

npm run serve
# 或者
yarn serve

如果你能看到一个旋转的绿色立方体,恭喜你,你的第一个 Vue + Three.js 应用就完成了!

第四章:数据驱动,让 3D 世界动起来

Vue 的核心是数据驱动视图。我们可以利用 Vue 的响应式数据,动态地控制 3D 场景中的各种元素。

修改 HelloWorld3D.vue 组件:

<template>
  <div ref="container" style="width: 100%; height: 500px;"></div>
  <input type="range" v-model.number="cubeSize" min="0.1" max="5" step="0.1">
</template>

<script>
import * as THREE from 'three';

export default {
  data() {
    return {
      cubeSize: 1
    };
  },
  watch: {
    cubeSize(newSize) {
      if (this.cube) {
        this.cube.geometry.dispose();
        this.cube.geometry = new THREE.BoxGeometry(newSize, newSize, newSize);
      }
    }
  },
  mounted() {
    this.init();
    this.animate();
  },
  beforeUnmount() {
    this.dispose(); // 在组件卸载时进行清理
  },
  methods: {
    init() {
      this.scene = new THREE.Scene();
      this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      this.camera.position.z = 5;

      this.renderer = new THREE.WebGLRenderer({ antialias: true });
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.$refs.container.appendChild(this.renderer.domElement);

      const geometry = new THREE.BoxGeometry(this.cubeSize, this.cubeSize, this.cubeSize); // 使用 data 中的 cubeSize
      const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
      this.cube = new THREE.Mesh(geometry, material);
      this.scene.add(this.cube);

      const ambientLight = new THREE.AmbientLight(0x404040); // soft white light
      this.scene.add(ambientLight);

      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
      directionalLight.position.set(1, 1, 1);
      this.scene.add(directionalLight);

      window.addEventListener('resize', this.onWindowResize);
    },
    animate() {
      requestAnimationFrame(this.animate);

      this.cube.rotation.x += 0.01;
      this.cube.rotation.y += 0.01;

      this.renderer.render(this.scene, this.camera);
    },
    onWindowResize() {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
    },
    dispose() {
      // 清理资源,防止内存泄漏
      if (this.cube) {
        this.cube.geometry.dispose();
        this.cube.material.dispose();
      }
      if (this.renderer) {
        this.renderer.dispose();
      }
      window.removeEventListener('resize', this.onWindowResize);
    }
  }
};
</script>

现在,你可以通过拖动滑块,动态地改变立方体的大小了!

第五章:组件化,让 3D 世界井井有条

Vue 的组件化思想非常适合构建复杂的 3D 应用。我们可以将 3D 场景中的各种元素,比如模型、光照、相机等等,都封装成独立的组件。

创建一个名为 MyModel.vue 的组件:

<template>
  <div ref="modelContainer"></div>
</template>

<script>
import * as THREE from 'three';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';

export default {
  props: {
    modelPath: {
      type: String,
      required: true
    }
  },
  mounted() {
    this.loadModel();
  },
  beforeUnmount() {
    this.dispose();
  },
  methods: {
    loadModel() {
      const loader = new GLTFLoader();

      loader.load(
        this.modelPath,
        (gltf) => {
          this.model = gltf.scene;
          this.$emit('model-loaded', this.model); // 触发事件,通知父组件模型加载完成
        },
        (xhr) => {
          console.log((xhr.loaded / xhr.total * 100) + '% loaded');
        },
        (error) => {
          console.error('An error happened', error);
        }
      );
    },
    dispose() {
      if (this.model) {
        this.model.traverse((child) => {
          if (child.isMesh) {
            child.geometry.dispose();
            if (child.material.map) child.material.map.dispose();
            child.material.dispose();
          }
        });
      }
    }
  }
};
</script>

HelloWorld3D.vue 中使用这个组件:

<template>
  <div ref="container" style="width: 100%; height: 500px;"></div>
  <MyModel modelPath="/models/scene.gltf" @model-loaded="onModelLoaded" />
</template>

<script>
import * as THREE from 'three';
import MyModel from './MyModel.vue';

export default {
  components: {
    MyModel
  },
  data() {
    return {
      model: null
    };
  },
  mounted() {
    this.init();
    this.animate();
  },
  beforeUnmount() {
    this.dispose(); // 在组件卸载时进行清理
  },
  methods: {
    init() {
      this.scene = new THREE.Scene();
      this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      this.camera.position.z = 5;

      this.renderer = new THREE.WebGLRenderer({ antialias: true });
      this.renderer.setSize(window.innerWidth, window.innerHeight);
      this.$refs.container.appendChild(this.renderer.domElement);

      const ambientLight = new THREE.AmbientLight(0x404040); // soft white light
      this.scene.add(ambientLight);

      const directionalLight = new THREE.DirectionalLight(0xffffff, 0.5);
      directionalLight.position.set(1, 1, 1);
      this.scene.add(directionalLight);

      window.addEventListener('resize', this.onWindowResize);
    },
    animate() {
      requestAnimationFrame(this.animate);

      if (this.model) {
        this.model.rotation.y += 0.01;
      }

      this.renderer.render(this.scene, this.camera);
    },
    onWindowResize() {
      this.camera.aspect = window.innerWidth / window.innerHeight;
      this.camera.updateProjectionMatrix();
      this.renderer.setSize(window.innerWidth, window.innerHeight);
    },
    onModelLoaded(model) {
      this.model = model;
      this.scene.add(this.model);
    },
    dispose() {
      // 清理资源,防止内存泄漏
      if (this.model) {
        this.model.traverse((child) => {
          if (child.isMesh) {
            child.geometry.dispose();
            if (child.material.map) child.material.map.dispose();
            child.material.dispose();
          }
        });
      }
      if (this.renderer) {
        this.renderer.dispose();
      }
      window.removeEventListener('resize', this.onWindowResize);
    }
  }
};
</script>

注意: 你需要将一个 GLTF 模型文件 (例如 scene.gltf) 放到 public/models/ 目录下。

第六章:性能优化,让 3D 世界飞起来

3D 渲染是非常消耗性能的。我们需要采取一些措施,来优化应用的性能。

  • 减少 Draw Calls: 尽量合并几何体,使用纹理图集,减少渲染的次数。
  • 使用 LOD (Level of Detail): 对于远处的物体,使用低精度的模型。
  • 开启 Frustum Culling: 只渲染在相机视野内的物体。
  • 使用 Shadow Mapping: 阴影会增加渲染的负担,可以适当降低阴影的质量。
  • 避免频繁更新材质: 尽量减少对材质的修改,因为这会导致重新编译 Shader。
  • 使用 WebGL2: WebGL2 提供了更多的优化特性,可以提高渲染性能。
  • 使用 Offscreen Canvas: 将 3D 渲染放在 Offscreen Canvas 中,可以避免阻塞主线程。
  • 使用 Web Workers: 将一些计算密集型的任务放在 Web Workers 中执行。
  • 减少不必要的渲染: 只在必要的时候才进行渲染。 例如,只在数据发生变化的时候才更新 3D 场景。

代码示例:开启 Frustum Culling

// 在创建 Mesh 之后
mesh.frustumCulled = true; // 开启 Frustum Culling

代码示例:使用 LOD

import * as THREE from 'three';
import { LOD } from 'three';

// 创建不同精度的模型
const highResModel = new THREE.Mesh(new THREE.SphereGeometry(1, 32, 32), new THREE.MeshBasicMaterial({ color: 0xff0000 }));
const mediumResModel = new THREE.Mesh(new THREE.SphereGeometry(1, 16, 16), new THREE.MeshBasicMaterial({ color: 0xff0000 }));
const lowResModel = new THREE.Mesh(new THREE.SphereGeometry(1, 8, 8), new THREE.MeshBasicMaterial({ color: 0xff0000 }));

// 创建 LOD 对象
const lod = new LOD();

// 添加不同精度的模型和距离
lod.addLevel(highResModel, 0); // 距离 0 时显示 highResModel
lod.addLevel(mediumResModel, 20); // 距离 20 时显示 mediumResModel
lod.addLevel(lowResModel, 50); // 距离 50 时显示 lowResModel

// 将 LOD 对象添加到场景中
scene.add(lod);

表格:性能优化策略总结

优化策略 描述 适用场景
减少 Draw Calls 合并几何体,使用纹理图集,减少渲染次数 场景中包含大量相似的物体
使用 LOD 对于远处的物体,使用低精度的模型 场景中包含大量细节丰富的物体,且相机可以远离这些物体
开启 Frustum Culling 只渲染在相机视野内的物体 场景中包含大量物体,但只有一部分物体在相机视野内
使用 Shadow Mapping 阴影会增加渲染的负担,可以适当降低阴影的质量 需要模拟阴影效果,但对性能要求较高
避免频繁更新材质 尽量减少对材质的修改,因为这会导致重新编译 Shader 需要动态修改材质的属性
使用 WebGL2 WebGL2 提供了更多的优化特性,可以提高渲染性能 浏览器支持 WebGL2
使用 Offscreen Canvas 将 3D 渲染放在 Offscreen Canvas 中,可以避免阻塞主线程 3D 渲染占用大量主线程资源,导致页面卡顿
使用 Web Workers 将一些计算密集型的任务放在 Web Workers 中执行 需要进行复杂的计算,但不想阻塞主线程
减少不必要的渲染 只在必要的时候才进行渲染,例如,只在数据发生变化的时候才更新 3D 场景 3D 场景变化频率较低,但每次渲染的开销较大

第七章:Babylon.js 的替代方案

如果你喜欢 Babylon.js,可以将上面的 Three.js 代码替换为 Babylon.js 代码。

安装 Babylon.js:

npm install babylonjs @babylonjs/loaders
# 或者
yarn add babylonjs @babylonjs/loaders

修改 HelloWorld3D.vue 组件:

<template>
  <div ref="container" style="width: 100%; height: 500px;"></div>
</template>

<script>
import * as BABYLON from 'babylonjs';
import 'babylonjs-loaders';

export default {
  mounted() {
    this.init();
  },
  beforeUnmount() {
    this.dispose();
  },
  methods: {
    init() {
      // 1. 创建引擎
      this.engine = new BABYLON.Engine(this.$refs.container, true);

      // 2. 创建场景
      this.scene = new BABYLON.Scene(this.engine);

      // 3. 创建相机
      this.camera = new BABYLON.ArcRotateCamera("Camera", 0, 0, 5, BABYLON.Vector3.Zero(), this.scene);
      this.camera.setPosition(new BABYLON.Vector3(0, 0, 5));
      this.camera.attachControl(this.$refs.container, true);

      // 4. 创建光照
      const light = new BABYLON.HemisphericLight("light", new BABYLON.Vector3(0, 1, 0), this.scene);

      // 5. 创建一个立方体
      const box = BABYLON.MeshBuilder.CreateBox("box", {size: 1}, this.scene);

      // 6. 运行渲染循环
      this.engine.runRenderLoop(() => {
        box.rotation.y += 0.01;
        this.scene.render();
      });

      // 7. 监听窗口大小变化
      window.addEventListener('resize', this.onWindowResize);
    },
    onWindowResize() {
      this.engine.resize();
    },
    dispose() {
      if (this.engine) {
        this.engine.dispose();
      }
      window.removeEventListener('resize', this.onWindowResize);
    }
  }
};
</script>

总结:Vue + 3D,未来可期

Vue 结合 Three.js 或 Babylon.js,为我们提供了一种高效、灵活的方式来构建 3D 可视化应用。只要掌握了 Vue 的组件化思想,数据驱动视图的特性,以及 Three.js 或 Babylon.js 的 3D 渲染能力,就能创造出令人惊艳的 3D 世界。希望今天的讲座能对你有所帮助!

Q & A 环节 (模拟)

观众: 我在加载大型模型的时候,页面总是卡顿,有什么好的解决方案吗?

我: 加载大型模型卡顿是很常见的问题。可以尝试以下方法:

  • 使用模型压缩: 将模型压缩成 glTF 格式,并使用 Draco 压缩。
  • 使用流式加载: 将模型分块加载,避免一次性加载整个模型。
  • 使用 Web Workers: 将模型加载放在 Web Workers 中执行,避免阻塞主线程。

观众: 如何在 3D 场景中实现交互?

我: 可以使用 Three.js 或 Babylon.js 提供的射线投射 (Raycasting) 功能,来检测鼠标点击或触摸事件是否击中了 3D 物体。然后,根据击中的物体,执行相应的操作。

观众: Three.js 和 Babylon.js 哪个更好?

我: 这是一个经典的问题。两者都有各自的优点和缺点。

  • Three.js: 社区活跃,资料丰富,学习曲线相对平缓。
  • Babylon.js: 功能更全面,提供了更多的内置工具,例如物理引擎、粒子系统等等。

选择哪个取决于你的具体需求和偏好。

希望这些回答能够解答大家的疑惑。感谢大家的参与!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注