Three.js 构建 3D 配置器
在这个教程中,我们将学习如何创建 3D 场景、将模型上传到其中、同步场景和编辑器,出于学习目的,在教程中我们仅允许更改产品的颜色,而不涉及更多的产品可选配置。
本教程的在线DEMO请访问这里 ,教程源码可以从Github下载。
1、项目设置
我将在项目中使用 Nuxt.js 以及 Vuetify 作为主要的 UI 库,但两者都是可选的,如果需要,你可以使用 React 甚至 原生JS 实现 3D 配置器。
如果使用此技术栈开发项目,那么可以参考以下步骤:
npm i three
最终应该在 package.json 中获得以下依赖项:
"dependencies": {
"@nuxtjs/vuetify": "^1.11.2",
"nuxt": "^2.0.0",
"three": "^0.116.1"
}
2、准备3D 模型
参考如下步骤为产品配置器准备3D模型:
- 在/static根目录下创建/model文件夹
- 查找 .gltf(json) 或 .glb(binary) 格式的模型(对于 Three.js 中不同类型的格式有不同的加载器——官方推荐使用 GLTFLoader)
- 下载并解压缩模型(存档可能包含扩展名为 .bin 和/textures文件夹的文件)
3、编写页面样式
样式表文件内容如下:
body, html {
height: 100vh;
overflow: hidden !important;
}
:focus {
outline:none;
border: none;
-webkit-box-shadow: none;
box-shadow: none;
}
.scene {
cursor: grab;
&:active {
cursor: grabbing;
}
}
.coverdiv {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
不要忘记将此文件添加到 nuxt.config 中:
css: ['~/assets/scss/styles.scss']
4、编写Vue组件
下面是layouts/default.vue文件:
<template>
<v-app>
<v-app-bar
app
dark
dense
absolute
color="black"
>
<span class="body-2">Made with ❤️ by Osorina Irina 🚀</span>
</v-app-bar>
<v-content>
<nuxt/>
</v-content>
</v-app>
</template>
下面是pages/index.vue文件:
<template>
<v-container
class="fill-height"
fluid
>
<v-row
class="content-row"
align="center"
justify="center"
>
<!-- Scene -->
<v-col cols="7">
<v-card
color="grey lighten-3"
outlined
tile
>
<Scene/>
</v-card>
</v-col>
<!-- Editor -->
<v-col cols="5">
<v-card
color="grey lighten-3"
outlined
tile
>
<Editor/>
</v-card>
</v-col>
</v-row>
</v-container>
</template>
<script>
import Scene from '@/components/Scene/Scene';
import Editor from '@/components/Editor/Editor';
export default {
components: {
Scene,
Editor
}
};
</script>
<style lang="scss" scoped>
// Full-height row cards
.content-row {
height: 100%;
& > .col {
height: 100%;
position: relative;
padding-top: 0;
& > .v-card {
height: 100%;
border-radius: 5px !important;
overflow: hidden;
}
}
}
</style>
下面是Editor组件:
<template>
<v-container class="editor-container">
<v-row ref="canvas-container">
<v-card class="coverdiv">
<canvas id="editor-canvas" ref="canvas"></canvas>
</v-card>
</v-row>
<v-row class="mt-2">
<v-col class="px-0">
<v-color-picker
class="mx-auto"
mode="hexa"
v-model="backgroundColor"
hide-mode-switch
@input="color => applyColor({ color })"
>
</v-color-picker>
</v-col>
</v-row>
</v-container>
</template>
<script>
const colorsMap = [
'#553EEA', '#1C1E1F', '#D6A989', '#8D93E8', '#555195',
'#001916', '#4D33EF', '#AC2739', '#F6F5F5', '#5FAE7A',
'#73697B', '#1B1B23', '#D67339', '#E08C07', '#067D8C',
'#DAD29A', '#82925B', '#A3624D', '#4F3334', '#4C272E',
'#EB6940', '#DD4C1F', '#D77167', '#A02326', '#ECFA1C'
];
export default {
data() {
return {
backgroundColor: '#2A2A2A'
};
},
created() {
this.$nuxt.$on('ACTIVE_MESH_CHANGED', this.applyColor);
if (process.browser) {
window.addEventListener('resize', this.onResize);
}
},
destroyed() {
this.$nuxt.$off('ACTIVE_MESH_CHANGED', this.applyColor);
if (process.browser) {
window.removeEventListener('resize', this.onResize);
}
},
mounted() {
this.container = this.$refs['canvas-container'];
this.ctx = this.$refs.canvas.getContext('2d');
this.onResize();
this.applyColor({ restore: true });
this.triggerUpdate();
},
methods: {
/**
* triggerUpdate - Notifying the scene component that changes have occurred
*/
triggerUpdate() {
this.$nuxt.$emit('MESH_UPDATE');
},
/**
* applyColor - Set canvas background color
*
* @param {string} color color (if the argument is not defined, will be applied random color)
* @param {boolean} restore restore current color
*
*/
applyColor({ color, restore = false } = {}) {
if (this.ctx) {
this.ctx.clearRect(0, 0, this.container.offsetWidth, this.container.offsetHeight);
if (!restore) {
const randomColor = colorsMap[Math.floor((Math.random() * colorsMap.length))];
this.backgroundColor = color ?? randomColor;
}
this.ctx.fillStyle = this.backgroundColor;
this.ctx.fillRect(0, 0, this.container.offsetWidth, this.container.offsetHeight);
this.triggerUpdate();
}
},
/**
* onResize - Window resize handler
*
*/
onResize() {
const [w, h] = [this.container.offsetWidth, this.container.offsetHeight];
this.ctx.canvas.width = w;
this.ctx.canvas.height = h;
this.applyColor({ restore: true });
}
}
};
</script>
<style lang="scss" scoped>
.editor-container {
height: 100%;
padding-bottom: 0;
.row {
height: 50%;
margin: 0;
}
}
</style>
代码解释如下:
画布元素( $refs.canvas
)
- 这是我们将在 3D 模型中的某个对象(网格)上复制为纹理的元素
- 它有一个 ID——我们在 Scene 组件中需要它
方法:
updateMesh()
— 必须在编辑器画布元素的每次更改时调用。我们在此方法中发出的MESH_UPDATE
事件将在场景组件中进行处理。applyColor()
— 更改编辑器画布的背景颜色(在参数中传递或从colorsMap
数组中随机传递)。onResize()
— 在窗口调整大小时调用,将父容器的大小分配给画布元素。如果想添加插入文本或形状的功能,那么最好需要添加在调整窗口大小时保存它们的比例——这可以在这种方法中完成。
选色器:
- 开箱即用的 Vuetify-colorpicker
- 使用方法处理@input事件
applyColor()
Vuetify-colorpicker 在这个演示中也有自己的画布面板,但我们对它不感兴趣——它只是一个特定的 vuetify 组件。
因此,为避免混淆,可以通过hide-canvas
属性隐藏画布面板并使用道具添加色板show-swatches
,然后我们的编辑器将看起来像这样。
5、将 Three.js 导入项目
可以在要使用它的组件中导入 Three.js:
import * as THREE from 'three';
或者,如果计划扩展和分解将使用此库的组件结构,那么可以在nuxt.config
中使用webpack.ProvidePlugin()
全局执行此操作,也可以在这里为我们稍后需要的 GLTFLoader 和 OrbitControls 添加别名。
/*
** Build configuration
*/
build: {
/*
** You can extend webpack config here
*/
extend(config) {
config.plugins.push(new webpack.ProvidePlugin({
THREE: 'three'
}));
}
}
如果 eslint 无法识别 THREE
- 你可能需要在你的.eslint.json中 globals
部分添加 THREE: true
。
6、初始化 Three.js 场景
为方便起见,我们将场景初始化所需的主要组件的创建放在一个单独的类中:在@ /components/Scene/js/文件夹中创建一个文件Scene.init.js。
可能有必要将这个全局类移到更靠近根目录的地方,但在这个实现中,我认为这不是必需的。
我也认为这里最好使用 VUE 3 合成 API ——也许我稍后会这样做,并将场景创建放在合成函数中。
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
class SceneInit {
constructor(rootEl) {
this.root = rootEl;
this.width = rootEl.clientWidth;
this.height = rootEl.clientHeight;
this.background = 0xEEEEEE;
this.canvas = document.createElement('canvas');
this.init();
this.update();
this.bindEvents();
}
init() {
this.initScene();
this.initLights();
this.initCamera();
this.initRenderer();
this.initControls();
this.root.appendChild(this.canvas);
}
initScene() {
this.scene = new THREE.Scene();
}
initLights() {
const ambient = new THREE.AmbientLight(0xFFFFFF, 0.9);
const point = new THREE.PointLight(0xCCCCCC, 0.1, 10);
const directional = new THREE.DirectionalLight(0xFFFFFF, 0.5);
this.scene.add(ambient);
this.scene.add(point);
this.scene.add(directional);
}
initCamera() {
const aspect = this.width / this.height;
this.camera = new THREE.PerspectiveCamera(
45,
aspect,
1,
1000
);
this.camera.position.z = 15;
this.camera.aspect = aspect;
this.camera.updateProjectionMatrix();
}
initRenderer() {
this.renderer = new THREE.WebGLRenderer({ antialias: true });
this.renderer.setSize(this.width, this.height);
this.renderer.setClearColor(this.background, 1);
this.canvas = this.renderer.domElement;
}
initControls() {
this.controls = new OrbitControls(
this.camera,
this.canvas
);
this.controls.minPolarAngle = (Math.PI * 1) / 6;
this.controls.maxPolarAngle = (Math.PI * 3) / 4;
this.controls.smooth = true;
this.controls.smoothspeed = 0.95;
this.controls.autoRotateSpeed = 2;
this.controls.maxDistance = 20;
this.controls.minDistance = 12;
this.controls.update();
}
render() {
this.camera.lookAt(this.scene.position);
this.renderer.render(this.scene, this.camera);
}
update() {
requestAnimationFrame(() => this.update());
this.controls.update();
this.render();
}
bindEvents() {
window.addEventListener('resize', () => this.onResize());
}
onResize() {
this.width = this.root.clientWidth;
this.height = this.root.clientHeight;
this.renderer.setSize(this.width, this.height);
this.camera.aspect = this.width / this.height;
this.camera.updateProjectionMatrix();
}
loadModel(model, callback) {
this.loader = new GLTFLoader();
this.loader.load(model, (gltf) => {
if (typeof callback === 'function') {
callback(gltf.scene);
}
this.scene.add(gltf.scene);
});
}
}
// To call our class as a function
const sceneInit = args => new SceneInit(args);
export default sceneInit;
可以在这里阅读关于创建 Three.js 场景的内容。简而言之:
为了能够用three.js实际显示任何东西,我们需要三样东西:场景、相机和渲染器,这样我们就可以用相机渲染场景。
在上面的SceneInit
类中,我们实现了所有这些:通过在init()
方法中调用适当的方法来依次初始化场景、相机和渲染器。
此外,我们将添加灯光、控件(OrbitControls — 负责与相机交互)和加载器(GPTLoader 用于加载 .gltf 或 .glb 格式的模型):
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
在所有这些步骤之后,我们将创建的渲染元素的 html 插入到调用我们组件的根目录中。
有些人可能认为我们已经完成了,但没有——事实上,如果我们现在创建一个类的新实例,将什么也看不到。在初始化场景的主要组件之后,我们现在需要在屏幕上绘制它——为此,我们调用update()
方法。每次屏幕更新时它都会重新绘制场景(对于标准屏幕,这大约是每秒 60 次)。
7、Scene.options.vue 组件
包含父Scene.vue 组件的控制元素(例如用于导航模型区域、切换模型视图模式的按钮)。该组件仅包含我们使用的按钮的标记,这些按钮在@click事件中调用适当的父类方法。
<template>
<div class="options">
<v-tooltip top color="black">
<template v-slot:activator="{ on }">
<v-btn
x-small
fab
v-on="on"
class="ml-2"
@click="$emit('setActiveMesh', { forward: false })"
>
<v-icon>mdi-ray-end-arrow</v-icon>
</v-btn>
</template>
<span>Previous headphone area</span>
</v-tooltip>
<v-tooltip top color="black">
<template v-slot:activator="{ on }">
<v-btn
x-small
fab
v-on="on"
class="ml-1 mr-2"
@click="$emit('setActiveMesh')"
>
<v-icon>mdi-ray-start-arrow</v-icon>
</v-btn>
</template>
<span>Next headphone area</span>
</v-tooltip>
<v-tooltip top color="black">
<template v-slot:activator="{ on }">
<v-btn
x-small
fab
v-on="on"
@click="$emit('toggleWireframes')"
>
<v-icon>{{ showWireframes ? 'mdi-eye-off' : 'mdi-eye' }}</v-icon>
</v-btn>
</template>
<span>{{ showWireframes ? ' Hide' : ' Show' }} wireframes</span>
</v-tooltip>
</div>
</template>
<script>
export default {
props: ['showWireframes']
};
</script>
<style lang="scss">
.options {
display: flex;
position: absolute;
bottom: 12px;
left: 14px;
right: 14px;
}
</style>
上述代码解释如下:
Import:
- SceneInit — 用于初始化三场景的辅助函数。
- SceneOptions — 子组件
- findArraySibling — 在数组中搜索相邻元素的辅助函数
/**
* findArraySibling - Find a sibling of the current element in array
*
* @param {Array} arr Array
* @param {Object} current Current element
* @param {String} pName Property name
* @param {Boolean} forward Which way to search (next element or previous)
*
* @return {Object} Array element
*/
const findArraySibling = ({
arr = [], current = {}, forward = true, pName = 'name'
} = {}) => {
const currentIdx = arr.findIndex(i => i[pName] === current[pName]);
const lastIdx = arr.length - 1;
const nextIdx = () => {
if (forward) {
return currentIdx === lastIdx ? 0 : currentIdx + 1;
}
return currentIdx === 0 ? lastIdx : currentIdx - 1;
};
return arr[nextIdx()];
};
export {
findArraySibling
};
Markup:
- 在标记中,我们只有 SceneOptions 组件和 vuetify-overlay。调用
SceneInit ()
时会自动插入场景,该函数又调用同名类。
data():
model
— 已加载模型objects
— 加载模型的子元素数组(区域、对象、网格——所有这些都意味着相同的东西)activeMesh
— 当前活动对象isLoaded
,showWireframes
— vuetify-overlay 和模型显示模式的布尔标志(带或不带线框)
computed:
editorCanvas
— 编辑器组件的 Canvas 元素,我们将使用new Three.texture(editorCanvas)
创建纹理,并将其分配给活动模型对象。同步的关键点。
hooks:
created()
,destroyed()
— 我们相应地订阅和取消订阅用于更新活动对象纹理的全局MESH_UPDATE
事件。mounted()
— 初始化场景并加载模型。
methods:
updatedMesh()
— 更新活动对象的纹理setActiveMesh()
— 设置活动对象并为其分配一个canvasTexture
从编辑器组件复制画布的对象。如果它是一个 mousedown 事件,我们在这里使用一个findArraySibling
帮助器(意味着它是从导航按钮调用的)createWireframe()
— 为单个模型对象创建线框toggleWireframes()
— 使用函数遍历所有模型对象.traverse()
,并根据当前showWireframes
标志设置单个线框的可见性值loadModel()
— 加载模型,向对象添加线框并调用setActiveMesh()
我们将加载模型的第一个对象作为参数传递的方法
总结一下如何实现场景和编辑器的同步:
- 创建复制编辑器组件画布的新纹理
- 将其作为活动对象(网格、区域)纹理的
map
选项传递 - 通过订阅从编辑器组件调用的事件来调用纹理更新 -
MESH_UPDATE
8、项目部署
最后一步是部署我们的项目。我们会将静态文件上传到 GH 页面。
让我们开始吧:
- 如果还没有这样做,请在 github 上创建一个repo
- 为路由器添加配置(将
router.base
设置为你的repo名称) - 在终端运行
npm generate
- 在 devDependecies 中安装
gh-pages
- 添加新的 package.json 脚本并运行它
"deploy": "gh-pages -d dist"
原文链接:Building a 3D Product Configurator with Nuxt and Three.js
BimAnt翻译整理,转载请标明出处