前端图形入门 - 在WebGL中实现3D物体的选中和操作

2,495 阅读5分钟

最近一年一直在做3D图形相关的应用,入坑前端图形也了一年,想在这里跟大家分享一下自己学习到一些图形基础知识。今天跟大家分享一下在Web中实现3D物体的选中和移动旋转缩放操作。

我们先看一下实现的最终效果:

j3d-pick.gif

实现思路

  1. 初始化3D场景
  2. 加载3D模型
  3. 处理mousemove事件和click事件
  4. 模型选中样式更新
  5. 菜单显示
  6. 菜单操作实现

实现说明

初始化3D场景

ThreeJS中的一些基本概念:场景、相机、灯光、渲染器、3D物体

  • Scene(场景):一个容器,用于放置各种物体:物体、灯等等
  • Camera(相机):模拟人眼观察,需要放置到场景中
  • Light(灯光):模拟各类灯光类型
  • Renderer(渲染器):用于渲染所有信息
  • Mesh(3D网格物体):Mesh信息

首先我们需要在ThreeJS中初始化场景

// 创建一个场景
this._scene = new THREE.Scene();
this._scene.background = new THREE.Color( 0xbbbbbb );
// 创建渲染器
this._renderer = new THREE.WebGLRenderer({
    canvas: this._canvas
});
const { width, height } = this._domContainer.getBoundingClientRect();
this._renderer.setSize( width, height);
this._renderer.setPixelRatio( window.devicePixelRatio );
// 相机
this._camera = new THREE.PerspectiveCamera( 75, window.innerWidth / window.innerHeight, 0.1, 1000 );
this._camera.position.set(80, 60, 0);
this._camera.lookAt(0,0,0);

加载3D模型

ThreeJS中可以使用API生成3D物体,也可以通过Loader加载其他3D格式的模型,如常见的obj模型或者其他glTF格式的模型数据,

  • 创建本地3D模型
const geometry = new THREE.BoxGeometry(10, 10, 10);
const material = new THREE.MeshBasicMaterial( { color: 0xc20053 } );
material.side = THREE.DoubleSide;
const mesh = new THREE.Mesh( geometry, material );
  • 加载第三方3D模型
// 使用GLTFLoader加载模型
const loader = new GLTFLoader().setPath('assets/');
loader.load('Soldier.glb', (gltf: GLTF) => {
    this._scene.add( gltf.scene );
});

添加灯光

在真实的世界中,我们能看到物体是因为眼睛接收到物体表面反射光,形成的图像,在我们这里使用HemisphereLight模拟一个简单的户外光照效果

const hemiLight = new THREE.HemisphereLight( 0xffffff, 0x444444 );
hemiLight.position.set( 0, 20, 0 );
this._scene.add(hemiLight);

渲染

渲染器、场景、物体、相机和灯光都准备好后,我们就可以在浏览器中渲染出这个模型


private _animationFrame = () => {
    window.requestAnimationFrame(this._animationFrame);
    if (this.application.renderNextFrame) {
        this._renderer.render(this._scene, this._camera);
    }
};
//帧循环渲染
window.requestAnimationFrame(this._animationFrame);

提问:requestAnimationFrame属于微任务还是宏任务(字节面试题)?

处理mousemove事件和click事件

经过上面的操作,我们已经可以在canvas中显示3D场景和物体了 j3d-pick.png 那我们要实现在不同角度对3D物体的选中,就需要在canvas中监听鼠标事件,根据鼠标的位置查找选中的物体信息

在3D中实现对物体的选中,就是在鼠标所在的位置发出一条摄像,和场景中的物体做相交,ThreeJS中可以直接使用Raycaster

this._mousePoint.x = event.clientX / this._domContainer.clientWidth * 2 - 1;
this._mousePoint.y = - ( event.clientY / this._domContainer.clientHeight ) * 2 + 1;
this._raycaster.setFromCamera( this._mousePoint, this._camera );
// this._meshes包含了所有物体信息
const intersects = this._raycaster.intersectObjects( this._meshes );

这里setFromCamera就是在当前鼠标位置和相机设置设定射线发射器,raycaster.intersectObjects来做射线和指定的物体相交,返回检测到的物体列表

需要注意的是,ThreeJS中,一个物体可能由N级单元构成,而射线只能检测到最小单元,在返回的intersects中,我们需要去找到对应的物体,所以在添加物体到场景中时,我们需要建立一个单元 -> 物体的映射

// gltf.scene是加载出来的一个机器人整体
gltf.scene.traverse( ( object: THREE.Object3D ) => {
    if (object instanceof THREE.Object3D) {
        // meshes存储所有物体单元信息
        this._meshes.push(object);
        // 存储单元 -> 机器人的映射
        this._objects.set(object.uuid, gltf.scene);
    }
});

根据raycaster.intersectObjects返回的信息,我们可以拿到当前鼠标选中的物体

if (intersects.length > 0) {
    // 选中的最小单元
    const mesh = intersects[0].object;
    // 找到对应的物体
    const picked = this._objects.get(mesh.uuid);
}

模型选中样式更新

这个时候我们已经拿到了选中的物体,那么就是要给个反馈呗,这里我们做个简单的样式更改,把选中的物体材质透明度改为原始值的一半,那么在下一帧刷新后,我们就可以看到一个透明的效果

mesh.traverse((child: THREE.Object3D) => {
    if (child instanceof THREE.Mesh) {
        const material = child.material as THREE.Material;
        // 存储之前的透明度,鼠标移出后记得把材质修改回来
        this._meshOpacity.set(child.uuid, material.opacity);
        // 设置透明度
        material.opacity = material.opacity / 3;
        material.transparent = true;
        material.needsUpdate = true;
    }
});

菜单显示

经过以上步骤后,我们就了解了在ThreeJS中如何去选中物体,那一般我们的业务需要选中后,有一些操作,就比如游戏中的走路,这里我们就来看看ThreeJS中最常见的三种变换:移动、旋转、缩放

当我们在3D中选中物体后,告诉我们的菜单,这个时候你该出场了,我项目中用了一个简单的发布/订阅者模式,当物体选中后,会向系统发出物体选中事件

const selections = objects.map((mesh) => new J3DSelection(mesh));
this.application.addSelections(selections);
this.application.emitSelectionAdd({
    selections,
    event
});

而在菜单功能那边,会监听选中事件

this._application.listenSelectionAdd(this._onSelectionAdded);

从而在收到物体选中事件后,把自己show出来

private _updateMenu = (event: MouseEvent) => {
    const { clientX, clientY } = event;
    this.setState({
        xPosition: clientX + 10,
        yPosition: clientY + 10,
        visible: this._application.selections.length > 0
    });
};

效果如下:

截屏2021-09-05 23.15.58.png

菜单操作实现

菜单展示出来后,点击对应的按钮,根据选中的物体信息执行相应的操作即可

移动

private _move = (direction: number) => {
    const delta: number = direction > 0 ? -5 : 5;
    this._application.selections.forEach((item) => {
        item.mesh.position.z = item.mesh.position.z + delta;
    });
};

旋转

private _rotate = (direction: number) => {
    const angle = direction > 0 ? -Math.PI / 4 : Math.PI / 4;
    this._application.selections.forEach((item) => {
        item.mesh.rotateY(angle);
    });
};

缩放

private _scale = (direction: number) => {
    const delta: number = direction > 0 ? 2 : 0.5;
    this._application.selections.forEach((item) => {
        const {x, y, z} = item.mesh.scale;
        item.mesh.scale.set(x * delta, y * delta, z * delta);
    });
};

总结

第一次写技术博客,主要是用一个直观的项目展示基于ThreeJS的3D基础知识,后续还会持续分享一些自己的前端和图形相关的知识,如有错误欢迎留言指正。

项目源码

Github地址:j3d-pick

源码说明

基础架构.png

官方文档

  • ThreeJS:基于WebGL封装的一个3D JS库,关于ThreeJS,可以看ThreeJS官网的文档说明
  • WebGL:基于OpenGL ES规范的Web端3D图形接口规范,更多知识可以看WebGL官网
  • OpenGL:用于访问图形硬件(GPU)的接口标准,OpenGL官网