NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - AI模型在线查看 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割 - 3D道路快速建模

在这个教程中,我们将学习如何创建 3D 场景、将模型上传到其中、同步场景和编辑器,出于学习目的,在教程中我们仅允许更改产品的颜色,而不涉及更多的产品可选配置。

本教程的在线DEMO请访问这里 ,教程源码可以从Github下载。

1、项目设置

我将在项目中使用 Nuxt.js 以及 Vuetify 作为主要的 UI 库,但两者都是可选的,如果需要,你可以使用 React 甚至 原生JS 实现 3D 配置器。

如果使用此技术栈开发项目,那么可以参考以下步骤:

  1. 根据官方文档安装nuxt-project
  2. 也按照说明将 Vuetify 添加为 nuxt 模块(另一种方法是将其安装为插件)
  3. 添加 Three.js 库
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()我们将加载模型的第一个对象作为参数传递的方法

总结一下如何实现场景和编辑器的同步:

  1. 创建复制编辑器组件画布的新纹理
  2. 将其作为活动对象(网格、区域)纹理的map选项传递
  3. 通过订阅从编辑器组件调用的事件来调用纹理更新 -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翻译整理,转载请标明出处