vue3+three开发

577 阅读1分钟

vue3 + three开发

安装three

pnpm i three -S

three常用说明

import * as THREE from 'three';
// 控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
// 第一人称控制器
import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls';
// 加载器
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
// label标签
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer';
// TWEEN 动画
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js';
// 单击选中模型高亮显示/选中模型发光
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';

创建three.vue文件

基本要素:

  • 场景:scene = new THREE.Scene()
  • 相机:camera = new THREE.PerspectiveCamera(45, Width / Height, 1, 2000)
  • 渲染器:renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
  • 控制器: controls = new OrbitControls(camera, renderer.domElement)
  • 灯光:new THREE.AmbientLight('#ffffff', 1)
  • 渲染:requestAnimationFrameIndex = requestAnimationFrame(render)

高级要素:

  • label:labelRenderer = new CSS2DRenderer()
  • 第一人称控制器:LockControls = new PointerLockControls(camera, container)
  • 模型按照固定路线运动:new THREE.CatmullRomCurve3()
  • 第一人称碰撞检测: new THREE.Raycaster()

注意:

  • 组件切换需使用 cancelAnimationFrame(requestAnimationFrameIndex) 清除渲染任务,并释放内存。
  • 使用第一人称时需要禁止 controls?.update() 更新。
  • 在使用合成器时,我们需要对应用程序的动画循环进行更改。 现在我们不再调用WebGLRenderer的render方法,而是使用EffectComposer中对应的render方法。
<template>
    <div :id="ID" @click="clickModel" style="position: relative" class="w100 h100">
        <div style="position: absolute; background-color: var(--el-overlay-color-lighter); z-index: 10" class="flex-center w100 h100" v-show="loading">
            <dv-decoration-9 :scale="2">
                <span class="fw" style="color: var(--el-mask-color); font-size: 30px"> {{ percentComplete }}% </span>
            </dv-decoration-9>
        </div>
        <div style="position: absolute; right: 10px; top: 10px" v-show="LockControlsShow">
            <el-button type="primary" @click="crateCar()">{{ inspect ? '停止' : '开始' }}巡检</el-button>
            <el-button type="success" @click="roamFn()">开始漫游</el-button>
        </div>
    </div>
</template>

<script setup name="demo7">
import { addResizeListener, removeResizeListener } from '@u/dom';
import { throttle, guid } from '@u/util';
import * as THREE from 'three';
// 控制器
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls';
import { PointerLockControls } from 'three/examples/jsm/controls/PointerLockControls';
// 加载器
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader';
import { DRACOLoader } from 'three/examples/jsm/loaders/DRACOLoader';
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader';
// label标签
import { CSS2DObject, CSS2DRenderer } from 'three/examples/jsm/renderers/CSS2DRenderer';
// TWEEN 动画
import { TWEEN } from 'three/examples/jsm/libs/tween.module.min.js';
// 单击选中模型高亮显示/选中模型发光
import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js';
import { OutlinePass } from 'three/examples/jsm/postprocessing/OutlinePass.js';
import { ShaderPass } from 'three/examples/jsm/postprocessing/ShaderPass.js';
import { FXAAShader } from 'three/examples/jsm/shaders/FXAAShader.js';

const ID = `THREEID${guid()}`;

let container, // 模型渲染的Demo节点
    renderer, //  WebGLRenderer渲染器
    labelRenderer, //  CSS2DRenderer渲染器
    scene, // 场景
    camera, // 相机
    controls, // 控制器
    LockControls, // 第一人称控制器
    loading = $ref(false),
    percentComplete = $ref(0);

// 初始化生命周期
let setupElResponsiveProxy;
onMounted(() => {
    container = document.getElementById(ID);
    setupElResponsiveProxy = throttle(function () {
        camera.aspect = container.offsetWidth / container.offsetHeight;
        camera.updateProjectionMatrix();
        renderer.setSize(container.offsetWidth, container.offsetHeight);
    });
    addResizeListener(container, setupElResponsiveProxy);
    init();
});
onBeforeUnmount(() => {
    removeResizeListener(container, setupElResponsiveProxy);
    cancelAnimationFrame(requestAnimationFrameIndex);
    // 释放显存
    renderer?.dispose();
    // 释放内存
    container = null;
    scene = null;
});

function init() {
    // 场景
    scene = new THREE.Scene();
    scene.background = new THREE.Color(0x808080);
    scene.fog = new THREE.Fog('#ffffff', 20, 500);
    // 辅助三维坐标系AxesHelper
    // scene.add(new THREE.AxesHelper(250));
    // 相机
    camera = new THREE.PerspectiveCamera(45, container.offsetWidth / container.offsetHeight, 1, 2000);
    camera.position.set(260, 100, 260); //设置相机位置
    setTimeout(() => {
        setTweens(camera.position, { x: -160, y: 60, z: 160 });
    });

    // 渲染器 // antialias开启抗锯齿 alpha开启背景透明
    renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(container.offsetWidth, container.offsetHeight);

    // 添加label场景
    labelRenderer = new CSS2DRenderer(); //新建CSS2DRenderer
    labelRenderer.setSize(container.offsetWidth, container.offsetHeight);
    labelRenderer.domElement.style.position = 'absolute';
    labelRenderer.domElement.style.top = 0;
    labelRenderer.domElement.style.pointerEvents = 'none';

    // 控制器
    controls = new OrbitControls(camera, renderer.domElement);
    // 动态阻尼
    controls.enableDamping = true;
    // 垂直轨道能绕多远,这是上限。范围是0到数学。圆周率,默认为Math.PI。
    controls.maxPolarAngle = 1.5;
    // 你可以移动多远(透视相机)。默认值为0。
    controls.minDistance = 10;
    controls.maxDistance = 1000;

    // 自然光
    scene.add(new THREE.AmbientLight('#ffffff', 1));
    // 第一人称控制器
    LockControls = new PointerLockControls(camera, container);
    scene.add(LockControls.getObject());
    // 加载器
    loaderFn();
    container.appendChild(renderer.domElement);
    container.appendChild(labelRenderer.domElement);
}

// 模型加载
let objects = [];
function loaderFn() {
    // 将平面添加到场景中
    createPlaneGeometryBasicMaterial();
    // 立方体
    createBoxGeometryBasicMaterial();
    creatRoadSurface();
    createRoundGeometryBasicMaterialMax();
    createRoundGeometryBasicMaterialMin();
    crateWall();
    // 调用渲染函数
    render();
}

// 创建圆仓大public\textures
let objLoader = new OBJLoader(),
    textureLoader = new THREE.TextureLoader(); // 贴图加载器
function createRoundGeometryBasicMaterialMax() {
    objLoader.load('/models/demo7/textures/gong001.obj', function (obj) {
        var mesh = obj.children[0];
        mesh.material = new THREE.MeshBasicMaterial({
            map: textureLoader.load('/models/demo7/textures/d001.png'),
            transparent: true,
            side: THREE.DoubleSide,
            clipIntersection: true,
        });
        mesh.rotateZ(Math.PI);
        mesh.position.set(-40, 36, -105);

        for (let i = 0; i < 2; i++) {
            for (let j = 0; j < 3; j++) {
                var mc = mesh.clone();
                mc.translateX(i * 28);
                mc.translateZ(j * 20);
                mc.name = '大存储罐-G' + (i + 1) * (j + 1);
                scene.add(mc);
                objects.push(mc);
                // let text = '大存储罐-G' + (i + 1) * (j + 1);
                // let vector = new THREE.Vector3(-40 - i * 28, 50, -105 + j * 20);
                // let pointLabel = createLableObj(text, vector);
                // scene.add(pointLabel);
            }
        }
    });
}

// 新建标签
function createLableObj(text, vector) {
    let laberDiv = document.createElement('div'); //创建div容器
    laberDiv.className = 'laber_name';
    laberDiv.textContent = text + '\n' + '余量:123';
    let pointLabel = new CSS2DObject(laberDiv);
    pointLabel.position.set(vector.x, vector.y, vector.z);
    return pointLabel;
}

// 创建圆仓小
function createRoundGeometryBasicMaterialMin() {
    objLoader.load('/models/demo7/textures/002.obj', function (obj) {
        var mesh = obj.children[0];
        mesh.material = new THREE.MeshBasicMaterial({
            map: textureLoader.load('/models/demo7/textures/002.png'),
            transparent: true,
            side: THREE.DoubleSide,
            clipIntersection: true,
        });
        mesh.rotateZ(Math.PI);
        mesh.position.set(-40, 20, -19);

        for (let i = 0; i < 2; i++) {
            for (let j = 0; j < 6; j++) {
                var mc = mesh.clone();
                mc.translateX(i * 28);
                mc.translateZ(j * 24);
                mc.name = '小存储罐-G' + (i + 1) * (j + 1);
                scene.add(mc);
                objects.push(mc);
                // let text = '小存储罐-G' + (i + 1) * (j + 1);
                // let vector = new THREE.Vector3(-40 - i * 28, 35, -19 + j * 24);
                // let pointLabel = createLableObj(text, vector);
                // scene.add(pointLabel);
            }
        }
    });
}

// 创建围栏
function crateWall() {
    objLoader.load('/models/demo7/textures/wall.obj', function (obj) {
        obj.scale.set(0.98, 0.6, 1);
        const texLan = textureLoader.load('/models/demo7/textures/lan2.png');
        // 纹理重复
        texLan.wrapS = texLan.wrapT = THREE.RepeatWrapping;
        texLan.repeat.set(40, 1);
        obj.children[0].material = new THREE.MeshBasicMaterial({
            side: THREE.DoubleSide,
            map: texLan,
            transparent: true,
        });
        obj.children[1].material = new THREE.MeshBasicMaterial({
            map: textureLoader.load('/models/demo7/textures/door.png'),
            side: THREE.DoubleSide,
            transparent: true,
        });
        scene.add(obj);
        objects.push(...obj.children);
    });
}

// 创建房屋
function createBoxGeometryBasicMaterial() {
    objLoader.load('/models/demo7/textures/003.obj', function (obj) {
        var mesh = obj.children[0];
        mesh.material = new THREE.MeshBasicMaterial({
            map: textureLoader.load('/models/demo7/textures/003.png'),
        });
        mesh.scale.set(1.3, 1.4, 1.5);
        mesh.position.set(11, 0, -85);

        for (let i = 0; i < 2; i++) {
            for (let j = 0; j < 3; j++) {
                var mc = mesh.clone();
                mc.translateX(i * 52);
                mc.translateZ(j * 83);
                mc.name = '工作厂房-W' + (i + 1) * (j + 1);
                scene.add(mc);
                objects.push(mc);
                let text = '工作厂房-W' + (i + 1) * (j + 1);
                let vector = new THREE.Vector3(11 + i * 52, 18, -85 + j * 83);
                let pointLabel = createLableObj(text, vector);
                scene.add(pointLabel);
            }
        }
    });
}

// 加载车辆模型
let modelCar;
function crateCar() {
    if (!modelCar) {
        // loading = true;
        setTweens(
            camera.position,
            { x: 180, y: 80, z: 200 },
            2000,
            function () {
                const dracoLoader = new DRACOLoader();
                dracoLoader.setDecoderPath('/models/demo1/gltf/');
                const loader = new GLTFLoader();
                loader.setDRACOLoader(dracoLoader);
                loader.load(
                    `/models/demo7/叉车.glb`,
                    function (object) {
                        modelCar = object.scene;
                        modelCar.scale.set(4, 4, 4);
                        modelCar.position.set(88, 0, 115);
                        modelCar.rotateY(Math.PI);
                        scene.add(modelCar);
                        crateCarPath();
                        loading = false;
                    },
                    function (xhr) {
                        if (xhr.lengthComputable) percentComplete = Math.round((xhr.loaded / xhr.total) * 100, 2);
                    }
                );
            },
            function () {
                percentComplete += 1;
            }
        );
    }
    inspect = !inspect;
}

// 车辆模型按照固定路线运动
let curve,
    inspect = $ref(false);
function crateCarPath() {
    curve = new THREE.CatmullRomCurve3(
        [
            new THREE.Vector3(86, 0, 115),
            new THREE.Vector3(86, 0, -115),
            new THREE.Vector3(37, 0, -115),
            new THREE.Vector3(37, 0, 115),
            new THREE.Vector3(-15, 0, 115),
            new THREE.Vector3(-15, 0, -115),
            new THREE.Vector3(-86, 0, -115),
            new THREE.Vector3(-86, 0, 115),
            new THREE.Vector3(-15, 0, 115),
            new THREE.Vector3(-15, 0, -115),
            new THREE.Vector3(37, 0, -115),
            new THREE.Vector3(37, 0, 115),
        ],
        true,
        'catmullrom',
        0.15
    );
    // 绘制轨迹线
    // let geometry = new THREE.BufferGeometry();
    // geometry.setFromPoints(curve.getPoints(10000));
    // let material = new THREE.LineDashedMaterial({ color: 0x4488ff });
    // scene.add(new THREE.Line(geometry, material));
}

/**
 * 第一人称漫游视角
 */
let LockControlsShow = $ref(true), // 显示漫游按钮
    velocity = new THREE.Vector3(), //移动速度变量
    direction = new THREE.Vector3(), //移动的方向变量
    moveForward = false, //是否向前运行
    moveBackward = false, //是否向后运行
    moveLeft = false, //是否向左运行
    moveRight = false, //是否向右运行
    canJump = true; //是否可以跳

function roamFn() {
    LockControls.addEventListener('lock', function () {
        LockControlsShow = false;
    });
    LockControls.addEventListener('unlock', function () {
        LockControlsShow = true;
    });
    setTweens(LockControls.getObject().position, { x: 88, y: 10, z: 120 }, 1500, function () {
        LockControls.lock();
    });
}

document.addEventListener('keydown', keydownFn, false);
document.addEventListener('keyup', keyupFn, false);

/**
 * 跳跃高度
 */
const JumpHeight = 60;

function keydownFn(event) {
    switch (event.keyCode) {
        case 38: // up
        case 87: // w
            moveForward = true;
            break;
        case 37: // left
        case 65: // a
            moveLeft = true;
            break;
        case 40: // down
        case 83: // s
            moveBackward = true;
            break;
        case 39: // right
        case 68: // d
            moveRight = true;
            break;
        case 32: // space
            if (canJump === true) velocity.y = JumpHeight;
            canJump = false;
            break;
    }
}

function keyupFn(event) {
    switch (event.keyCode) {
        case 38: // up
        case 87: // w
            moveForward = false;
            break;
        case 37: // left
        case 65: // a
            moveLeft = false;
            break;
        case 40: // down
        case 83: // s
            moveBackward = false;
            break;
        case 39: // right
        case 68: // d
            moveRight = false;
            break;
    }
}

/**
 * 创建地面并添加材质
 * wrapS属性定义的是纹理沿x轴方向的行为,而warpT属性定义的是纹理沿y轴方向的行为。
 * Three.js为这些属性提供了如下两个选项:
 * ·THREE.RepeatWrapping允许纹理重复自己。
 * ·THREE.ClampToEdgeWrapping是属性的默认值。
 * 属性值为THREE.ClampToEdgeWrapping时,那么纹理的整体不会重复,只会重复纹理边缘的像素来填满剩下的空间。
 */
function createPlaneGeometryBasicMaterial() {
    const cubeMaterial = new THREE.MeshStandardMaterial({
        map: textureLoader.load('/models/demo7/textures/floor3.png'),
        transparent: true,
        side: THREE.DoubleSide,
    });
    // 创建地平面并设置大小
    let planeGeometry = new THREE.PlaneGeometry(190, 260);
    const plane = new THREE.Mesh(planeGeometry, cubeMaterial);
    // 设置平面位置并旋转
    plane.rotateX(-Math.PI / 2);
    scene.add(plane);
}

// 创建路面
function creatRoadSurface() {
    const geometry = new THREE.PlaneGeometry(24, 190);
    const texture = textureLoader.load('/models/demo7/textures/road2.png');
    texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
    texture.repeat.set(1, 10);
    const material = new THREE.MeshBasicMaterial({
        map: texture,
        side: THREE.DoubleSide,
    });
    const mesh = new THREE.Mesh(geometry, material);
    scene.add(mesh);
    mesh.rotateX(-Math.PI / 2);
    mesh.rotateZ(-Math.PI / 2);
    mesh.position.z = 142;
}

/**
 * @description TWEEN动画
 */
function setTweens(obj, newObj, duration = 1500, callBack, onUpdate) {
    var ro = new TWEEN.Tween(obj);
    ro.to(newObj, duration); // 变化后的位置以及动画时间
    ro.easing(TWEEN.Easing.Sinusoidal.InOut);
    ro.onUpdate(function () {
        onUpdate?.();
    });
    ro.onComplete(function () {
        callBack?.();
    });
    ro.start();
}

/**
 * 传入角度即方向 判断附近有没有障碍物 返回障碍物数量
 */
function collideCheck(angle) {
    let rotationMatrix = new THREE.Matrix4();
    rotationMatrix.makeRotationY((angle * Math.PI) / 180);
    const cameraDirection = LockControls.getDirection(new THREE.Vector3(0, 0, 0)).clone();
    cameraDirection.applyMatrix4(rotationMatrix);
    const raycaster = new THREE.Raycaster(LockControls.getObject().position.clone(), cameraDirection, 0, 5);
    const intersections = raycaster.intersectObjects(objects, false);
    return intersections.length;
}

// 渲染逻辑
let progress = 0,
    prevTime,
    requestAnimationFrameIndex;

/**
 * 眼睛位置
 */
const eyeHeight = 8;

//四个方位是否产生碰撞
let leftCollide = false,
    rightCollide = false,
    forwardCollide = false,
    backCollide = false;
function render() {
    requestAnimationFrameIndex = requestAnimationFrame(render);
    // 巡检车辆移动
    if (curve && inspect) {
        progress += 0.0002;
        if (progress > 1) progress = 0;
        //取路径上当前点的坐标
        const { x, y, z } = curve.getPointAt(progress);
        const { x: x2, y: y2, z: z2 } = curve.getPointAt(progress + 0.0001);
        //设置车模型坐标为在相机路径上当前点的位置
        modelCar?.position?.set(x, y, z);
        // 转向
        modelCar.lookAt(x2, y2, z2);
    }
    // 漫游
    const time = performance.now();
    if (LockControls?.isLocked === true) {
        const delta = (time - prevTime) / 1000;

        //velocity每次的速度,为了保证有过渡
        velocity.x -= velocity.x * 10.0 * delta;
        velocity.z -= velocity.z * 10.0 * delta;
        velocity.y -= 9.8 * 10.0 * delta; // 默认下降的速度

        direction.z = Number(moveForward) - Number(moveBackward);
        direction.x = Number(moveLeft) - Number(moveRight);
        //将法向量的值归一化
        direction.normalize();

        //注意velocity向量,这是一个缓冲值,为了保证鼠标抬起后,场景不直接暂停,而是有一个简短的过渡效果
        if (moveForward || moveBackward) velocity.z -= direction.z * 100.0 * delta;
        if (moveLeft || moveRight) velocity.x -= direction.x * 100.0 * delta;

        let rightDistance = velocity.x * delta;
        let forwardDistance = -velocity.z * delta;
        /**
         * 碰撞检测 collide check
         */
        if (moveForward) forwardCollide = collideCheck(0);
        if (moveBackward) backCollide = collideCheck(180);
        if (moveLeft) leftCollide = collideCheck(90);
        if (moveRight) rightCollide = collideCheck(270);

        //右侧有障碍物时向右移动 置零
        if ((moveRight && rightCollide) || (moveLeft && leftCollide)) {
            rightDistance = 0;
        }
        //前方有障碍物时向前移动 置零
        if ((moveForward && forwardCollide) || (moveBackward && backCollide)) {
            forwardDistance = 0;
        }
        LockControls.getObject().position.y += velocity.y * delta; // new behavior
        // 方向移动
        LockControls?.moveRight(rightDistance);
        LockControls?.moveForward(forwardDistance);

        if (LockControls.getObject().position.y < eyeHeight) {
            velocity.y = 0;
            LockControls.getObject().position.y = eyeHeight;
            canJump = true;
        }
    } else {
        controls?.update();
    }
    prevTime = time;
    // TWEEN动画
    TWEEN?.update();
    // label标签渲染
    labelRenderer.render(scene, camera);
    /**
     * 在使用合成器时,我们需要对应用程序的动画循环进行更改。 现在我们不再调用WebGLRenderer的render方法,而是使用EffectComposer中对应的render方法。
     */
    if (!composer) renderer?.render(scene, camera);
    composer?.render();
}

// 点击事件
const raycaster = new THREE.Raycaster(),
    mouse = new THREE.Vector2();
function clickModel(e) {
    mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
    mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
    raycaster.setFromCamera(mouse, camera);
    scene.add(new THREE.ArrowHelper(raycaster.ray.direction, raycaster.ray.origin, 300, 0xff0000));
    var intersects = raycaster.intersectObjects(objects); //
    //获取选中最近的Mesh对象
    //instance坐标是对象,右边是类,判断对象是不是属于这个类的
    if (intersects.length !== 0 && intersects[0].object.type === 'Mesh') {
        selectedObjectsFn(intersects[0].object);
    }
}
let composer, outlinePass;
// 高亮显示模型(呼吸灯)
function outlineObj() {
    // 创建一个EffectComposer(效果组合器)对象,然后在该对象上添加后期处理通道。
    composer = new EffectComposer(renderer);
    // 新建一个场景通道  为了覆盖到原理来的场景上
    const renderPass = new RenderPass(scene, camera);
    composer.addPass(renderPass);
    // 物体边缘发光通道
    outlinePass = new OutlinePass(new THREE.Vector2(container.offsetWidth, container.offsetHeight), scene, camera);
    outlinePass.selectedObjects = [];
    // 边框的亮度
    outlinePass.edgeStrength = 10.0;
    // 光晕[0,1]
    outlinePass.edgeGlow = 1;
    // 是否使用父级的材质
    outlinePass.usePatternTexture = false;
    // 边框宽度
    outlinePass.edgeThickness = 1.0;
    // 边框弯曲度
    outlinePass.downSampleRatio = 1;
    // 呼吸闪烁的速度
    outlinePass.pulsePeriod = 5;
    // 呼吸显示的颜色
    outlinePass.visibleEdgeColor.set(parseInt(0xffff00));
    // 呼吸消失的颜色
    outlinePass.hiddenEdgeColor = new THREE.Color(0, 0, 0);
    outlinePass.clear = true;
    composer.addPass(outlinePass);
    // 自定义的着色器通道 作为参数
    const effectFXAA = new ShaderPass(FXAAShader);
    effectFXAA.uniforms.resolution.value.set(1 / container.offsetWidth, 1 / container.offsetHeight);
    effectFXAA.renderToScreen = true;
    composer.addPass(effectFXAA);
}
// 发光的对象是个数组
function selectedObjectsFn(mesh, type = 'add') {
    if (!composer) outlineObj();
    const index = outlinePass.selectedObjects.findIndex((item) => item.name == mesh.name);
    if (index === -1 && type === 'add') {
        // 发光的对象是个数组
        outlinePass.selectedObjects.push(mesh);
    } else if (index !== -1) {
        outlinePass.selectedObjects.splice(index, 1);
    }
}
</script>

<style>
.laber_name {
    color: rgb(0, 255, 255);
    width: 100px;
    height: 30px;
    font-size: 14px;
    padding: 15px;
    background: url('/models/demo7/textures/msg-bg.png') no-repeat;
    background-size: cover;
    cursor: pointer;
    pointer-events: auto;
}
</style>

GIF 2022-9-9 16-21-41.gif